From 50369b7e15c7584348e4b310252824af5049d5a4 Mon Sep 17 00:00:00 2001 From: Ben Date: Mon, 7 Sep 2026 16:50:38 -0600 Subject: [PATCH 1/6] Add Bukkit-free shared libraries and safe Configurate configuration I/O Build core, Configurate and SQL artifacts from the existing maintained sources without changing the legacy SimpleAPI POM or existing public APIs. Add revisioned local YAML editing, native SQL configuration, packaged classpath checks and pinned AdvancedCore/VotingPlugin compatibility builds. --- .github/workflows/shared-libraries.yml | 99 +++++++ .gitignore | 2 + .../simpleapi/file/config/ConfigDocument.java | 23 ++ .../simpleapi/file/config/ConfigEditor.java | 15 + .../simpleapi/file/config/ConfigSnapshot.java | 11 + .../configurate/ConfigurateConfigView.java | 133 +++++++++ .../configurate/YamlConfigDocument.java | 270 ++++++++++++++++++ .../sql/mysql/config/MysqlConfigView.java | 38 +++ docs/shared-libraries.md | 167 +++++++++++ pom.xml | 47 +++ simpleapi-configurate/pom.xml | 31 ++ .../ConfigurateConfigViewTest.java | 125 ++++++++ .../configurate/YamlConfigDocumentTest.java | 217 ++++++++++++++ simpleapi-core/pom.xml | 50 ++++ .../simpleapi/core/CoreClasspathTest.java | 17 ++ simpleapi-sql/pom.xml | 42 +++ .../simpleapi/sql/mysql/SharedSqlTest.java | 39 +++ tools/ConfigParityProbe.java | 54 ++++ tools/NativeConfigSmoke.java | 36 +++ tools/SharedArtifactProbe.java | 41 +++ tools/verify-shared-artifacts.py | 62 ++++ 21 files changed, 1519 insertions(+) create mode 100644 .github/workflows/shared-libraries.yml create mode 100644 .gitignore create mode 100644 SimpleAPI/src/main/java/com/bencodez/simpleapi/file/config/ConfigDocument.java create mode 100644 SimpleAPI/src/main/java/com/bencodez/simpleapi/file/config/ConfigEditor.java create mode 100644 SimpleAPI/src/main/java/com/bencodez/simpleapi/file/config/ConfigSnapshot.java create mode 100644 SimpleAPI/src/main/java/com/bencodez/simpleapi/file/config/configurate/ConfigurateConfigView.java create mode 100644 SimpleAPI/src/main/java/com/bencodez/simpleapi/file/config/configurate/YamlConfigDocument.java create mode 100644 SimpleAPI/src/main/java/com/bencodez/simpleapi/sql/mysql/config/MysqlConfigView.java create mode 100644 docs/shared-libraries.md create mode 100644 pom.xml create mode 100644 simpleapi-configurate/pom.xml create mode 100644 simpleapi-configurate/src/test/java/com/bencodez/simpleapi/file/config/configurate/ConfigurateConfigViewTest.java create mode 100644 simpleapi-configurate/src/test/java/com/bencodez/simpleapi/file/config/configurate/YamlConfigDocumentTest.java create mode 100644 simpleapi-core/pom.xml create mode 100644 simpleapi-core/src/test/java/com/bencodez/simpleapi/core/CoreClasspathTest.java create mode 100644 simpleapi-sql/pom.xml create mode 100644 simpleapi-sql/src/test/java/com/bencodez/simpleapi/sql/mysql/SharedSqlTest.java create mode 100644 tools/ConfigParityProbe.java create mode 100644 tools/NativeConfigSmoke.java create mode 100644 tools/SharedArtifactProbe.java create mode 100644 tools/verify-shared-artifacts.py diff --git a/.github/workflows/shared-libraries.yml b/.github/workflows/shared-libraries.yml new file mode 100644 index 00000000..d4e54375 --- /dev/null +++ b/.github/workflows/shared-libraries.yml @@ -0,0 +1,99 @@ +name: Shared libraries and downstream compatibility + +on: + pull_request: + branches: [main] + push: + branches: [main, codex/simpleapi-shared-libraries] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: shared-libraries-${{ github.ref }} + cancel-in-progress: true + +jobs: + verify: + runs-on: ubuntu-latest + timeout-minutes: 25 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: temurin + cache: maven + # Pinned compatibility fixtures, not moving branches or a release/deployment job. + - uses: actions/checkout@v4 + with: + repository: BenCodez/AdvancedCore + ref: 3f66545590644d87ec45d7864d831691a07830ac + path: .compat/AdvancedCore + persist-credentials: false + - uses: actions/checkout@v4 + with: + repository: BenCodez/VotingPlugin + ref: 350caccb12053a12bb47bbe22012b875580a77d2 + path: .compat/VotingPlugin + persist-credentials: false + - name: Isolate Maven cache + shell: bash + run: | + set -euo pipefail + repo="$RUNNER_TEMP/simpleapi-candidate-m2" + mkdir -p "$repo" + if [ -d "$HOME/.m2/repository" ]; then cp -a "$HOME/.m2/repository/." "$repo/"; fi + echo "CANDIDATE_M2=$repo" >> "$GITHUB_ENV" + - name: Build shared libraries and unchanged legacy artifact + run: mvn -B -ntp -Dmaven.repo.local="$CANDIDATE_M2" clean install + - name: Collect runtime classpaths + shell: bash + run: | + set -euo pipefail + mvn -B -ntp -nsu -Dmaven.repo.local="$CANDIDATE_M2" -pl simpleapi-core,simpleapi-configurate,simpleapi-sql -am org.apache.maven.plugins:maven-dependency-plugin:3.8.1:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/runtime-deps + mvn -B -ntp -nsu -Dmaven.repo.local="$CANDIDATE_M2" -f SimpleAPI/pom.xml org.apache.maven.plugins:maven-dependency-plugin:3.8.1:build-classpath -Dmdep.outputFile=target/compatibility-classpath.txt + - name: Verify actual packaged artifacts and Bukkit parity + run: python3 tools/verify-shared-artifacts.py + - name: Pin candidate dependency bytes + shell: bash + run: | + set -euo pipefail + candidate="$CANDIDATE_M2/com/bencodez/simpleapi/1.0.2-SNAPSHOT/simpleapi-1.0.2-SNAPSHOT.jar" + cmp SimpleAPI/target/SimpleAPI.jar "$candidate" + sha256sum "$candidate" > "$RUNNER_TEMP/simpleapi-candidate.sha256" + - name: Build AdvancedCore against candidate + shell: bash + run: | + set -euo pipefail + mvn -B -ntp -nsu -Dmaven.repo.local="$CANDIDATE_M2" -f .compat/AdvancedCore/AdvancedCore/pom.xml clean install + sha256sum -c "$RUNNER_TEMP/simpleapi-candidate.sha256" + mvn -B -ntp -nsu -Dmaven.repo.local="$CANDIDATE_M2" -f .compat/AdvancedCore/AdvancedCore/pom.xml org.apache.maven.plugins:maven-dependency-plugin:3.8.1:build-classpath -Dmdep.outputFile=target/candidate-classpath.txt + grep -F "$CANDIDATE_M2/com/bencodez/simpleapi/1.0.2-SNAPSHOT/simpleapi-1.0.2-SNAPSHOT.jar" .compat/AdvancedCore/AdvancedCore/target/candidate-classpath.txt + sha256sum "$CANDIDATE_M2/com/bencodez/advancedcore/3.8.2-SNAPSHOT/advancedcore-3.8.2-SNAPSHOT.jar" > "$RUNNER_TEMP/advancedcore-candidate.sha256" + - name: Build VotingPlugin against candidate chain + shell: bash + run: | + set -euo pipefail + mvn -B -ntp -nsu -Dmaven.repo.local="$CANDIDATE_M2" -f .compat/VotingPlugin/VotingPlugin/pom.xml clean verify + sha256sum -c "$RUNNER_TEMP/simpleapi-candidate.sha256" + sha256sum -c "$RUNNER_TEMP/advancedcore-candidate.sha256" + mvn -B -ntp -nsu -Dmaven.repo.local="$CANDIDATE_M2" -f .compat/VotingPlugin/VotingPlugin/pom.xml org.apache.maven.plugins:maven-dependency-plugin:3.8.1:build-classpath -Dmdep.outputFile=target/candidate-classpath.txt + grep -F "$CANDIDATE_M2/com/bencodez/advancedcore/3.8.2-SNAPSHOT/advancedcore-3.8.2-SNAPSHOT.jar" .compat/VotingPlugin/VotingPlugin/target/candidate-classpath.txt + - name: Preserve test reports and candidate artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: shared-library-validation + retention-days: 7 + if-no-files-found: warn + include-hidden-files: true + path: | + simpleapi-*/target/*.jar + simpleapi-*/target/surefire-reports/*.xml + SimpleAPI/target/surefire-reports/*.xml + .compat/AdvancedCore/AdvancedCore/target/surefire-reports/*.xml + .compat/VotingPlugin/VotingPlugin/target/surefire-reports/*.xml diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..6d3e90ac --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +**/target/ +.compat/ diff --git a/SimpleAPI/src/main/java/com/bencodez/simpleapi/file/config/ConfigDocument.java b/SimpleAPI/src/main/java/com/bencodez/simpleapi/file/config/ConfigDocument.java new file mode 100644 index 00000000..f570d27e --- /dev/null +++ b/SimpleAPI/src/main/java/com/bencodez/simpleapi/file/config/ConfigDocument.java @@ -0,0 +1,23 @@ +package com.bencodez.simpleapi.file.config; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.function.Consumer; + +/** + * Synchronous local-file configuration operations. Call from an I/O worker, not + * a server/entity thread. Reads and failed operations never create empty files + * or replace a last-good snapshot. The caller owns exclusive writes to the file; + * content revisions detect observed external edits, not cross-process file CAS. + */ +public interface ConfigDocument { + Path path(); + ConfigSnapshot snapshot(); + ConfigSnapshot reload() throws IOException; + /** + * Edits a private copy, persists it, then publishes the new snapshot. Any + * validation, stale-revision or pre-publication I/O failure leaves the current + * in-memory snapshot unchanged. The callback must not reenter document writes. + */ + ConfigSnapshot update(String expectedRevision, Consumer edit) throws IOException; +} diff --git a/SimpleAPI/src/main/java/com/bencodez/simpleapi/file/config/ConfigEditor.java b/SimpleAPI/src/main/java/com/bencodez/simpleapi/file/config/ConfigEditor.java new file mode 100644 index 00000000..1ee333c1 --- /dev/null +++ b/SimpleAPI/src/main/java/com/bencodez/simpleapi/file/config/ConfigEditor.java @@ -0,0 +1,15 @@ +package com.bencodez.simpleapi.file.config; + +/** + * A short-lived editor supplied by ConfigDocument.update. Retaining an editor + * does not grant access to future document state. Implementations reject writes + * after the callback returns. Values are plain scalar/list/string-keyed map trees; + * native player/item objects must be converted by the owning platform first. + */ +public interface ConfigEditor extends ConfigView { + /** Sets a value using the document's separator. Null removes the value. */ + void set(String path, Object value); + /** Sets a value using literal key segments, including keys containing dots. */ + void setAt(Object value, String... keys); + default void remove(String path) { set(path, null); } +} diff --git a/SimpleAPI/src/main/java/com/bencodez/simpleapi/file/config/ConfigSnapshot.java b/SimpleAPI/src/main/java/com/bencodez/simpleapi/file/config/ConfigSnapshot.java new file mode 100644 index 00000000..d345a134 --- /dev/null +++ b/SimpleAPI/src/main/java/com/bencodez/simpleapi/file/config/ConfigSnapshot.java @@ -0,0 +1,11 @@ +package com.bencodez.simpleapi.file.config; + +import java.util.Objects; + +/** Detached read view and opaque content revision used to reject stale edits. */ +public record ConfigSnapshot(String revision, ConfigView view) { + public ConfigSnapshot { + Objects.requireNonNull(revision, "revision"); + Objects.requireNonNull(view, "view"); + } +} diff --git a/SimpleAPI/src/main/java/com/bencodez/simpleapi/file/config/configurate/ConfigurateConfigView.java b/SimpleAPI/src/main/java/com/bencodez/simpleapi/file/config/configurate/ConfigurateConfigView.java new file mode 100644 index 00000000..8c5f44b2 --- /dev/null +++ b/SimpleAPI/src/main/java/com/bencodez/simpleapi/file/config/configurate/ConfigurateConfigView.java @@ -0,0 +1,133 @@ +package com.bencodez.simpleapi.file.config.configurate; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.regex.Pattern; + +import org.spongepowered.configurate.ConfigurationNode; + +import com.bencodez.simpleapi.file.config.ConfigView; + +/** + * Read-only view of a Configurate section. Scalar numbers/booleans are read + * strictly, rather than using Configurate's more permissive string coercions. + * This matches the Bukkit scalar/list rules used by the existing binder. + * + *

Paths use '.' by default; at(String...) uses literal segments for identifiers + * containing dots. An externally supplied node is a live view and the caller must + * coordinate its mutation. YamlConfigDocument supplies detached snapshots.

+ * + *

Getter/annotation defaults are supported. Bukkit's mutable default-section + * overlay and native serialized Bukkit objects are deliberately not emulated.

+ */ +public class ConfigurateConfigView implements ConfigView { + protected final ConfigurationNode node; + private final char separator; + + public ConfigurateConfigView(ConfigurationNode node) { this(node, '.'); } + + public ConfigurateConfigView(ConfigurationNode node, char separator) { + this.node = Objects.requireNonNull(node, "node"); + this.separator = separator; + } + + protected final String[] segments(String path) { + Objects.requireNonNull(path, "path"); + return path.isEmpty() ? new String[0] : path.split(Pattern.quote(String.valueOf(separator)), -1); + } + + protected final ConfigurationNode resolve(String... keys) { + Objects.requireNonNull(keys, "keys"); + ConfigurationNode current = node; + for (String key : keys) { + Objects.requireNonNull(key, "key"); + ConfigurationNode match = null; + // YAML numeric keys remain addressable as strings, without replacing + // them with a second, differently typed key when an editor writes. + for (Map.Entry entry : current.childrenMap().entrySet()) { + if (String.valueOf(entry.getKey()).equals(key)) { + if (match != null) throw new IllegalArgumentException("Ambiguous configuration key"); + match = entry.getValue(); + } + } + current = match == null ? current.node(key) : match; + } + return current; + } + + /** Returns a section using literal key segments, or null for an absent/non-map node. */ + public ConfigurateConfigView at(String... keys) { + ConfigurationNode child = resolve(keys); + return child.isMap() || child == node && child.isNull() + ? new ConfigurateConfigView(child, separator) : null; + } + + @Override public boolean contains(String path) { + return path.isEmpty() || !resolve(segments(path)).isNull(); + } + @Override public String getString(String path, String fallback) { + ConfigurationNode child = resolve(segments(path)); + if (child.isMap()) return fallback; // no Bukkit section toString emulation + Object value = child.raw(); + return value == null ? fallback : value.toString(); + } + @Override public boolean getBoolean(String path, boolean fallback) { + Object value = resolve(segments(path)).rawScalar(); + return value instanceof Boolean bool ? bool : fallback; + } + @Override public int getInt(String path, int fallback) { + Object value = resolve(segments(path)).rawScalar(); + return value instanceof Number number ? number.intValue() : fallback; + } + @Override public long getLong(String path, long fallback) { + Object value = resolve(segments(path)).rawScalar(); + return value instanceof Number number ? number.longValue() : fallback; + } + @Override public double getDouble(String path, double fallback) { + Object value = resolve(segments(path)).rawScalar(); + return value instanceof Number number ? number.doubleValue() : fallback; + } + @Override public List getStringList(String path) { + List result = new ArrayList<>(); + for (ConfigurationNode child : resolve(segments(path)).childrenList()) { + Object value = child.rawScalar(); + if (value instanceof String || value instanceof Number || value instanceof Boolean || value instanceof Character) + result.add(value.toString()); + } + return result; + } + @Override public List getIntegerList(String path) { + List result = new ArrayList<>(); + for (ConfigurationNode child : resolve(segments(path)).childrenList()) { + Object value = child.rawScalar(); + if (value instanceof Number number) result.add(number.intValue()); + else if (value instanceof Character character) result.add((int) character); + else if (value instanceof String string) { + try { result.add(Integer.parseInt(string)); } catch (NumberFormatException ignored) { } + } + } + return result; + } + @Override public boolean isConfigurationSection(String path) { return at(segments(path)) != null; } + @Override public ConfigurateConfigView getConfigurationSection(String path) { return at(segments(path)); } + @Override public Set getKeys(boolean deep) { + LinkedHashSet result = new LinkedHashSet<>(); + collectKeys(node, "", deep, result, 0); + return result; + } + private void collectKeys(ConfigurationNode parent, String prefix, boolean deep, Set keys, int depth) { + if (depth > 64) throw new IllegalArgumentException("Configuration nesting is too deep"); + Set siblings = new LinkedHashSet<>(); + for (Map.Entry entry : parent.childrenMap().entrySet()) { + String key = String.valueOf(entry.getKey()); + if (!siblings.add(key)) throw new IllegalArgumentException("Ambiguous configuration key"); + String path = prefix + key; + keys.add(path); + if (deep && entry.getValue().isMap()) collectKeys(entry.getValue(), path + separator, true, keys, depth + 1); + } + } +} diff --git a/SimpleAPI/src/main/java/com/bencodez/simpleapi/file/config/configurate/YamlConfigDocument.java b/SimpleAPI/src/main/java/com/bencodez/simpleapi/file/config/configurate/YamlConfigDocument.java new file mode 100644 index 00000000..fb79ca14 --- /dev/null +++ b/SimpleAPI/src/main/java/com/bencodez/simpleapi/file/config/configurate/YamlConfigDocument.java @@ -0,0 +1,270 @@ +package com.bencodez.simpleapi.file.config.configurate; + +import java.io.BufferedWriter; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.NoSuchFileException; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.nio.file.attribute.BasicFileAttributes; +import java.nio.file.attribute.PosixFilePermission; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.IdentityHashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.function.Consumer; + +import org.spongepowered.configurate.ConfigurationNode; +import org.spongepowered.configurate.yaml.NodeStyle; +import org.spongepowered.configurate.yaml.YamlConfigurationLoader; + +import com.bencodez.simpleapi.file.config.ConfigDocument; +import com.bencodez.simpleapi.file.config.ConfigEditor; +import com.bencodez.simpleapi.file.config.ConfigSnapshot; + +/** + * YAML document for administrator-owned local configuration. No file is created + * by open/reload. Updates use private copies, content revisions, a bounded staged + * write and an atomic replacement; there is no truncate-in-place fallback. + * + *

The parent directory must exist and be trusted. Target symlinks/non-regular + * files are rejected. The caller owns exclusive cross-process writes: revision + * checks detect observed edits but cannot make an atomic filesystem compare/swap. + * File bytes are forced before rename; power-loss durability of directory entries + * and preservation of non-POSIX ACLs are not promised.

+ * + *

Parsing uses Configurate's YAML loader and its parser policies. This is not + * a hostile YAML upload endpoint. Formatting/inline comments and native Bukkit + * serialized objects are not a lossless migration contract. Existing Bukkit and + * Velocity file implementations are not changed by this class.

+ */ +public final class YamlConfigDocument implements ConfigDocument { + public static final int DEFAULT_MAX_BYTES = 1024 * 1024; + private static final int MAX_NODES = 100_000; + private static final int MAX_DEPTH = 64; + private final Path path; + private final int maxBytes; + private ConfigurationNode root; + private String revision; + private boolean editing; + + private YamlConfigDocument(Path path, int maxBytes) { + this.path = path; + this.maxBytes = maxBytes; + } + + public static YamlConfigDocument open(Path path) throws IOException { return open(path, DEFAULT_MAX_BYTES); } + + public static YamlConfigDocument open(Path path, int maxBytes) throws IOException { + Objects.requireNonNull(path, "path"); + if (maxBytes < 1 || maxBytes > 16 * DEFAULT_MAX_BYTES) + throw new IllegalArgumentException("maxBytes must be between 1 byte and 16 MiB"); + Path absolute = path.toAbsolutePath().normalize(); + if (absolute.getFileName() == null) throw new IOException("A configuration filename is required"); + // Resolve the trusted parent once. Never create parent directories as a side effect of reading. + Path canonical = absolute.getParent().toRealPath().resolve(absolute.getFileName()); + YamlConfigDocument document = new YamlConfigDocument(canonical, maxBytes); + document.reload(); + return document; + } + + @Override public Path path() { return path; } + + @Override public synchronized ConfigSnapshot snapshot() { + return new ConfigSnapshot(revision, new ConfigurateConfigView(root.copy())); + } + + @Override public synchronized ConfigSnapshot reload() throws IOException { + if (editing) throw new IllegalStateException("Cannot reload inside an edit callback"); + byte[] bytes = readCurrent(); + ConfigurationNode candidate = parse(bytes == null ? new byte[0] : bytes); + ConfigSnapshot result = new ConfigSnapshot(revisionOf(bytes), new ConfigurateConfigView(candidate.copy())); + root = candidate; + revision = result.revision(); + return result; + } + + @Override public synchronized ConfigSnapshot update(String expectedRevision, Consumer edit) throws IOException { + Objects.requireNonNull(expectedRevision, "expectedRevision"); + Objects.requireNonNull(edit, "edit"); + if (editing) throw new IllegalStateException("Cannot nest document updates"); + if (!revision.equals(expectedRevision)) throw new IOException("Stale in-memory configuration revision"); + requireUnchanged(); + editing = true; + Editor editor = new Editor(root.copy()); + try { + try { edit.accept(editor); } finally { editor.active = false; } + validate(editor.node, 0, new int[1]); + ConfigurationNode candidate = editor.node.copy(); + byte[] encoded = serialize(candidate); + String nextRevision = revisionOf(encoded); + ConfigSnapshot result = new ConfigSnapshot(nextRevision, new ConfigurateConfigView(candidate.copy())); + replace(encoded); + root = candidate; + revision = nextRevision; + return result; + } finally { + editing = false; + } + } + + private void requireUnchanged() throws IOException { + if (!revision.equals(revisionOf(readCurrent()))) + throw new IOException("Configuration changed on disk; reload before applying an edit"); + } + + private byte[] readCurrent() throws IOException { + BasicFileAttributes attributes; + try { attributes = Files.readAttributes(path, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS); } + catch (NoSuchFileException missing) { return null; } + if (!attributes.isRegularFile() || attributes.isSymbolicLink()) + throw new IOException("Configuration target must be a regular file, not a symbolic link"); + if (attributes.size() > maxBytes) throw new IOException("Configuration exceeds byte limit"); + try (FileChannel channel = FileChannel.open(path, StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS)) { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + ByteBuffer buffer = ByteBuffer.allocate(8192); + while (channel.read(buffer) != -1) { + buffer.flip(); + int count = buffer.remaining(); + if (count > maxBytes - bytes.size()) throw new IOException("Configuration exceeds byte limit"); + bytes.write(buffer.array(), 0, count); + buffer.clear(); + } + return bytes.toByteArray(); + } + } + + private ConfigurationNode parse(byte[] bytes) throws IOException { + String text = StandardCharsets.UTF_8.newDecoder().onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT).decode(ByteBuffer.wrap(bytes)).toString(); + ConfigurationNode candidate = YamlConfigurationLoader.builder().buildAndLoadString(text); + if (candidate.isNull()) candidate.raw(new LinkedHashMap()); + if (!candidate.isMap()) throw new IOException("Configuration root must be a mapping"); + try { validate(candidate, 0, new int[1]); } + catch (IllegalArgumentException invalid) { throw new IOException("Unsupported configuration structure", invalid); } + return candidate; + } + + private byte[] serialize(ConfigurationNode candidate) throws IOException { + LimitedOutput output = new LimitedOutput(maxBytes); + try (BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(output, StandardCharsets.UTF_8))) { + YamlConfigurationLoader.builder().nodeStyle(NodeStyle.BLOCK).sink(() -> writer).build().save(candidate); + } + return output.bytes.toByteArray(); + } + + private void replace(byte[] encoded) throws IOException { + requireUnchanged(); + Set permissions = null; + if (Files.getFileAttributeView(path.getParent(), java.nio.file.attribute.PosixFileAttributeView.class) != null) { + permissions = revision.equals("missing") + ? Set.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE) + : Files.getPosixFilePermissions(path, LinkOption.NOFOLLOW_LINKS); + } + Path temporary = Files.createTempFile(path.getParent(), ".simpleapi-config-", ".tmp"); + try { + if (permissions != null) Files.setPosixFilePermissions(temporary, permissions); + try (FileChannel channel = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) { + ByteBuffer buffer = ByteBuffer.wrap(encoded); + while (buffer.hasRemaining()) channel.write(buffer); + channel.force(true); + } + requireUnchanged(); + Files.move(temporary, path, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } finally { + Files.deleteIfExists(temporary); + } + } + + private static String revisionOf(byte[] bytes) { + if (bytes == null) return "missing"; + try { return "sha256:" + HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)); } + catch (NoSuchAlgorithmException impossible) { throw new IllegalStateException("SHA-256 unavailable", impossible); } + } + + private static void validate(ConfigurationNode node, int depth, int[] count) { + if (depth > MAX_DEPTH || ++count[0] > MAX_NODES) throw new IllegalArgumentException("Configuration structure exceeds limits"); + if (node.isMap()) { + Set keys = new LinkedHashSet<>(); + for (Map.Entry entry : node.childrenMap().entrySet()) { + Object key = entry.getKey(); + if (!(key instanceof String || key instanceof Number || key instanceof Boolean || key instanceof Character) + || !keys.add(key.toString())) throw new IllegalArgumentException("Unsupported or ambiguous configuration key"); + validate(entry.getValue(), depth + 1, count); + } + } else if (node.isList()) { + for (ConfigurationNode child : node.childrenList()) validate(child, depth + 1, count); + } + } + + private static Object copyValue(Object value, int depth, int[] count, IdentityHashMap ancestors) { + if (depth > MAX_DEPTH || ++count[0] > MAX_NODES) throw new IllegalArgumentException("Configuration value exceeds structure limits"); + if (value == null || value instanceof String || value instanceof Boolean + || value instanceof Byte || value instanceof Short || value instanceof Integer || value instanceof Long + || value instanceof Float || value instanceof Double || value instanceof java.math.BigInteger + || value instanceof java.math.BigDecimal) return value; + if (value instanceof Character character) return character.toString(); + if (ancestors.put(value, Boolean.TRUE) != null) throw new IllegalArgumentException("Cyclic configuration values are not supported"); + try { + if (value instanceof Map map) { + Map copy = new LinkedHashMap<>(); + for (Map.Entry entry : map.entrySet()) { + if (!(entry.getKey() instanceof String key)) throw new IllegalArgumentException("Edited map keys must be strings"); + copy.put(key, copyValue(entry.getValue(), depth + 1, count, ancestors)); + } + return copy; + } + if (value instanceof List list) { + List copy = new ArrayList<>(); + for (Object child : list) copy.add(copyValue(child, depth + 1, count, ancestors)); + return copy; + } + throw new IllegalArgumentException("Only scalar, list and string-keyed map values are supported"); + } finally { ancestors.remove(value); } + } + + private static final class Editor extends ConfigurateConfigView implements ConfigEditor { + private final Thread owner = Thread.currentThread(); + private boolean active = true; + private Editor(ConfigurationNode node) { super(node); } + @Override public void set(String path, Object value) { setAt(value, segments(path)); } + @Override public void setAt(Object value, String... keys) { + if (!active || Thread.currentThread() != owner) throw new IllegalStateException("Editor is no longer active on this thread"); + Objects.requireNonNull(keys, "keys"); + if (keys.length == 0 || keys.length > MAX_DEPTH) throw new IllegalArgumentException("A nonempty bounded key path is required"); + for (String key : keys) if (key == null || key.isEmpty()) throw new IllegalArgumentException("Empty/null key segment"); + Object copy = copyValue(value, 0, new int[1], new IdentityHashMap<>()); + resolve(keys).raw(copy); + } + } + + private static final class LimitedOutput extends OutputStream { + private final int limit; + private final ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + private LimitedOutput(int limit) { this.limit = limit; } + @Override public void write(int value) throws IOException { + if (bytes.size() >= limit) throw new IOException("Serialized configuration exceeds byte limit"); + bytes.write(value); + } + @Override public void write(byte[] value, int offset, int length) throws IOException { + if (length > limit - bytes.size()) throw new IOException("Serialized configuration exceeds byte limit"); + bytes.write(value, offset, length); + } + } +} diff --git a/SimpleAPI/src/main/java/com/bencodez/simpleapi/sql/mysql/config/MysqlConfigView.java b/SimpleAPI/src/main/java/com/bencodez/simpleapi/sql/mysql/config/MysqlConfigView.java new file mode 100644 index 00000000..42e958f4 --- /dev/null +++ b/SimpleAPI/src/main/java/com/bencodez/simpleapi/sql/mysql/config/MysqlConfigView.java @@ -0,0 +1,38 @@ +package com.bencodez.simpleapi.sql.mysql.config; + +import java.util.Objects; +import com.bencodez.simpleapi.file.config.ConfigView; +import com.bencodez.simpleapi.sql.mysql.DbType; + +/** Platform-neutral snapshot of the existing MySQL section keys and defaults. */ +public final class MysqlConfigView extends MysqlConfig { + public MysqlConfigView(ConfigView section) { + Objects.requireNonNull(section, "section"); + setTablePrefix(section.getString("Prefix", null)); + String tableName = section.getString("Name", ""); + if (tableName != null && !tableName.isEmpty()) setTableName(tableName); + setHostName(section.getString("Host", null)); + setPort(section.getInt("Port", 0)); + setUser(section.getString("Username", null)); + setPass(section.getString("Password", null)); + setDatabase(section.getString("Database", null)); + setLifeTime(section.getLong("MaxLifeTime", -1)); + setMaxThreads(Math.max(1, section.getInt("MaxConnections", 1))); + setMinimumIdle(section.getInt("MinimumIdle", 2)); + setIdleTimeoutMs(section.getLong("IdleTimeoutMs", 10 * 60_000L)); + setKeepaliveMs(section.getLong("KeepaliveMs", 5 * 60_000L)); + setValidationMs(section.getLong("ValidationMs", 5_000L)); + setLeakDetectMs(section.getLong("LeakDetectMs", 20_000L)); + setConnectionTimeout(section.getInt("ConnectionTimeout", 50_000)); + String type = section.getString("DbType", ""); + setDbType(type != null && !type.isEmpty() ? DbType.fromString(type) + : section.getBoolean("UseMariaDB", false) ? DbType.MARIADB : DbType.MYSQL); + setDriver(section.getString("Driver", "")); + setUseSSL(section.getBoolean("UseSSL", false)); + setPublicKeyRetrieval(section.getBoolean("PublicKeyRetrieval", false)); + setUseMariaDB(section.getBoolean("UseMariaDB", false)); + setLine(section.getString("Line", "")); + setDebug(section.getBoolean("Debug", false)); + setPoolName(section.getString("PoolName", "")); + } +} diff --git a/docs/shared-libraries.md b/docs/shared-libraries.md new file mode 100644 index 00000000..6e8a1fb8 --- /dev/null +++ b/docs/shared-libraries.md @@ -0,0 +1,167 @@ +# Shared SimpleAPI libraries + +This follows the configuration binding foundation in PR #75. It adds buildable, +separately publishable, Bukkit-free artifacts and production configuration I/O. +It does not port VotingPlugin or AdvancedCore, and does not change their releases. + +## Artifacts + +| Artifact | Contents | Runtime dependencies | +| --- | --- | --- | +| `com.bencodez:simpleapi-core` | Existing annotation binder and annotation types, `ConfigView`, duration support, debug enum, SQL data values/columns; new document/editor/snapshot contracts | JDK 21 only | +| `com.bencodez:simpleapi-configurate` | `ConfigurateConfigView` and `YamlConfigDocument` | Core and Configurate YAML 4.2.0 | +| `com.bencodez:simpleapi-sql` | Existing JDBC/Hikari connection management, MySQL wrapper, abstract tables, queries and configuration model; new `MysqlConfigView` | Core and HikariCP 7.0.2 | +| `com.bencodez:simpleapi` | Existing full Bukkit/proxy distribution, including the shared classes | Existing dependency set, unchanged | + +The new modules currently use version `1.0.2-SNAPSHOT`, aligned with SimpleAPI. +They are libraries, not independently installed server mods. SQL drivers are still +selected/provided by the consuming application; publishing the SQL module does not +silently add a JDBC driver or change any existing database behavior/schema. + +### One maintained implementation, two packaging targets + +All implementation source stays under `SimpleAPI/src/main/java`, including the +new APIs. The shared module POMs stage explicit allow-lists into their own +`target/generated-sources/shared` directories. This is generated build input, +not a maintained source fork. Their javac source paths never include the rest of +the Bukkit tree. Source JARs contain the staged implementation as well. + +This layout deliberately preserves the old standalone build, source packages, +shading, and artifact coordinates while making the same implementation available +to native loaders. No existing class body, public signature, or legacy POM is +changed. Full distribution users can use the new APIs without adding new Maven +coordinates. + +**Do not package both representations of the same classes.** A native application +uses the shared artifacts and never the full `simpleapi` artifact. A Bukkit +application uses the full distribution. When a future AdvancedCore common module +brings shared artifacts transitively into a Bukkit distribution, exclude those +three dependencies from that edge and satisfy their classes from the aligned full +SimpleAPI dependency. Keep versions aligned. Existing AdvancedCore/VotingPlugin +builds do not need this exclusion today because they do not consume the new modules. + +The thin SQL artifact intentionally uses unrelocated HikariCP. Existing full +SimpleAPI shading remains unchanged. A native mod packager must package its actual +runtime dependency graph using the target loader's supported mechanism. + +## Native configuration example + +```java +Path directory = Path.of("config", "votingplugin"); +Files.createDirectories(directory); // explicit application-owned setup +ConfigDocument document = YamlConfigDocument.open(directory.resolve("VoteSites.yml")); +ConfigSnapshot before = document.snapshot(); +ConfigSnapshot after = document.update(before.revision(), edit -> { + edit.set("PointsOnVote", 1); + edit.set("Rewards.Commands", List.of("give player minecraft:diamond")); + edit.setAt(Map.of("ServiceSite", "example.site"), "VoteSites", "example.site"); +}); +new AnnotationBinder().load(after.view(), options); +``` + +Shared section-annotated fields use `ConfigView`. Existing Bukkit callers keep +using `AnnotationHandler` and receive native `ConfigurationSection` fields as in +PR #75. The old API has no new overloads or altered coercion rules. + +`ConfigurateConfigView` reads numeric and boolean scalars strictly, matching the +Bukkit getter rules rather than converting arbitrary strings to numbers/bools. +String/integer lists preserve the legacy element conversions and empty-list +annotation fallbacks. It supports a selectable path separator and literal key +segments through `at(String...)`. Numeric YAML keys remain addressable as strings; +ambiguous string representations are rejected by document validation. + +Getter and annotation defaults work. Bukkit's mutable configuration-default tree, +section `toString()` output, and Bukkit-serialized item/player objects are not +emulated. Parse-format-specific behavior is not claimed to be a lossless Bukkit +YAML migration. Port native object representations in the owning platform adapter. + +## Document safety and ownership + +All methods are synchronous: call file operations on an I/O worker, not a game or +entity thread. `open` and `reload` never create a file or parent directory. Missing +files produce an empty snapshot with revision `missing`; invalid/unreadable files +throw. A failed reload retains the last-good in-memory configuration. + +An update takes an expected revision and edits a private copy. Callback/validation +failure discards that copy. The editor is limited to its callback thread and cannot +write after the callback. Input collections and returned snapshots are detached +from future document state. Edits accept ordinary scalar/list/string-keyed-map +values, not native server objects or cyclic graphs. + +The writer checks the expected revision and current disk content, bounds serialized +bytes, writes a temporary sibling file, forces its contents, and atomically replaces +the target. There is no truncate-in-place or non-atomic replacement fallback. Target +symlinks and non-regular files are rejected. Existing POSIX permission bits are +retained; new POSIX files are owner-read/write only. Temporary files are cleaned up. + +Default size limit: 1 MiB (explicitly configurable from 1 byte to 16 MiB). Parsed/ +edited trees are limited to 64 levels and 100,000 nodes. UTF-8 decoding reports +malformed input instead of silently replacing bytes. + +This is for administrator-owned local files. Parent directories must be trusted. +The caller must own cross-process writes: revision checks detect observed external +edits, but an unrelated process can race the final check/rename. This is not a +filesystem compare-and-swap, database transaction, or hostile YAML upload validator. +Parsing uses Configurate's YAML parser policies. Inline comments/formatting, file +ownership/non-POSIX ACLs and directory-entry power-loss durability are not promised. +Do not wire this directly to an untrusted network editor without the application's +path, authentication, YAML-complexity, revision and secret-masking controls. + +## Building and publishing + +Existing command and distribution remain available: + +```sh +mvn -B -f SimpleAPI/pom.xml package +``` + +Build/test/install all artifacts, including the full legacy distribution: + +```sh +mvn -B clean install +``` + +Build only shared modules and the parent (no server APIs needed): + +```sh +mvn -B -pl simpleapi-core,simpleapi-configurate,simpleapi-sql -am clean install +``` + +A release operator can publish the new artifacts and their parent using existing +Nexus credentials. CI does not run this command: + +```sh +mvn -B -Pdeploy-shared -pl simpleapi-core,simpleapi-configurate,simpleapi-sql -am deploy +``` + +Existing Jenkins/legacy publication remains untouched; it will not automatically +publish the new coordinates until its operator adds the shared publication step. +Keep all four artifacts' versions aligned when releasing. + +## Verification + +`shared-libraries.yml` builds the shared and full artifacts on JDK 21, runs the +existing headless/duration tests and new configuration tests, checks packaged JAR +class boundaries, and compiles a consumer against packaged JARs (not target/classes). +A separate probe compares actual Bukkit scalar/list and SQL configuration behavior. +Source staging is checked byte-for-byte against the one maintained implementation. + +The same workflow installs the exact candidate in an isolated Maven repository, +builds pinned AdvancedCore and VotingPlugin fixtures, verifies their dependency +classpath paths and checks that installed candidate JAR hashes were not replaced. +It never merges, publishes releases, contacts production, or submits a dependency +graph on a pull request. Update the fixture SHAs deliberately when testing newer +consumer source. SQL tests here cover configuration/linkage, not a live database +matrix. Live Minecraft/Folia/Forge/Fabric smoke tests remain application-level work. + +## What remains for the platform port + +SimpleAPI's reusable configuration/duration/data/SQL layer is now packaged for +consumption without Bukkit. AdvancedCore still needs its own shared runtime and +platform-specific execution boundaries. The existing Bukkit/Folia scheduler, +player, messaging, item, inventory and mixed `ArrayUtils` APIs remain untouched; +their game operations belong behind adapters during that extraction. Do not replace +entity-aware scheduling with a generic global-thread executor. + +The active HTTP transport PR #73 is deliberately independent. It is not copied or +reworked here. Its final shared packaging can be added after that transport lands. diff --git a/pom.xml b/pom.xml new file mode 100644 index 00000000..272edff6 --- /dev/null +++ b/pom.xml @@ -0,0 +1,47 @@ + + + 4.0.0 + com.bencodez + simpleapi-parent + 1.0.2-SNAPSHOT + pom + SimpleAPI shared libraries + + + simpleapi-core + simpleapi-configurate + simpleapi-sql + SimpleAPI + + + UTF-8 + 21 + 1.18.44 + 6.0.3 + 4.2.0 + + + org.projectlomboklombok${lombok.version}provided + org.junit.jupiterjunit-jupiter${junit.version}test + + + org.apache.maven.pluginsmaven-compiler-plugin3.15.0 + 21none + org.projectlomboklombok${lombok.version} + + + org.apache.maven.pluginsmaven-resources-plugin3.4.0 + org.apache.maven.pluginsmaven-surefire-plugin3.5.4 + org.apache.maven.pluginsmaven-jar-plugin3.5.0 + org.apache.maven.pluginsmaven-source-plugin3.3.1 + org.apache.maven.pluginsmaven-install-plugin3.1.4 + org.apache.maven.pluginsmaven-deploy-plugin3.1.4 + org.codehaus.mojobuild-helper-maven-plugin3.6.0 + + + deploy-shared + nexushttps://nexus.bencodez.com/repository/maven-snapshots/ + nexushttps://nexus.bencodez.com/repository/maven-releases/ + + diff --git a/simpleapi-configurate/pom.xml b/simpleapi-configurate/pom.xml new file mode 100644 index 00000000..afd68651 --- /dev/null +++ b/simpleapi-configurate/pom.xml @@ -0,0 +1,31 @@ + + + 4.0.0 + com.bencodezsimpleapi-parent1.0.2-SNAPSHOT + simpleapi-configurate + SimpleAPI Configurate adapter + + com.bencodezsimpleapi-core${project.version} + org.spongepoweredconfigurate-yaml${configurate.version} + org.junit.jupiterjunit-jupiter + + + org.apache.maven.pluginsmaven-resources-plugin + stage-existing-shared-sourcesgenerate-sourcescopy-resources + ${project.build.directory}/generated-sources/shared + ${project.basedir}/../SimpleAPI/src/main/javafalse + com/bencodez/simpleapi/file/config/configurate/*.java + + + + org.codehaus.mojobuild-helper-maven-plugin + existing-shared-sourcesgenerate-sourcesadd-source + ${project.build.directory}/generated-sources/shared + + + org.apache.maven.pluginsmaven-source-plugin + sourcespackagejar-no-fork + + + diff --git a/simpleapi-configurate/src/test/java/com/bencodez/simpleapi/file/config/configurate/ConfigurateConfigViewTest.java b/simpleapi-configurate/src/test/java/com/bencodez/simpleapi/file/config/configurate/ConfigurateConfigViewTest.java new file mode 100644 index 00000000..c7d95063 --- /dev/null +++ b/simpleapi-configurate/src/test/java/com/bencodez/simpleapi/file/config/configurate/ConfigurateConfigViewTest.java @@ -0,0 +1,125 @@ +package com.bencodez.simpleapi.file.config.configurate; + +import static org.junit.jupiter.api.Assertions.*; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.spongepowered.configurate.BasicConfigurationNode; +import org.spongepowered.configurate.ConfigurationNode; +import org.spongepowered.configurate.yaml.YamlConfigurationLoader; +import com.bencodez.simpleapi.file.annotation.*; +import com.bencodez.simpleapi.file.config.ConfigView; +import com.bencodez.simpleapi.time.ParsedDuration; + +class ConfigurateConfigViewTest { + @Test void scalarReadsPreserveTypesAndExplicitDefaults() { + ConfigurationNode root = BasicConfigurationNode.root(); + root.node("text").raw(42); + root.node("trueString").raw("true"); + root.node("numberString").raw("12"); + root.node("flag").raw(false); + root.node("empty").raw(""); + root.node("zero").raw(0); + root.node("large").raw(4_000_000_000L); + root.node("decimal").raw(2.9); + ConfigView view = new ConfigurateConfigView(root); + assertEquals("42", view.getString("text", "fallback")); + assertFalse(view.getBoolean("trueString", false)); + assertEquals(99, view.getInt("numberString", 99)); + assertFalse(view.getBoolean("flag", true)); + assertEquals("", view.getString("empty", "fallback")); + assertEquals(0, view.getInt("zero", 99)); + assertEquals(4_000_000_000L, view.getLong("large", -1)); + assertEquals(2, view.getInt("decimal", -1)); + assertEquals(2.9, view.getDouble("decimal", -1)); + assertEquals(99L, view.getLong("missing", 99)); + } + + @Test void missingReadsDoNotCreateNodes() { + ConfigurationNode root = BasicConfigurationNode.root(); + ConfigView view = new ConfigurateConfigView(root); + assertFalse(view.contains("missing")); + assertNull(view.getConfigurationSection("missing.child")); + assertEquals(List.of(), view.getStringList("missing.list")); + assertEquals(List.of(), view.getIntegerList("missing.list")); + assertEquals(Set.of(), view.getKeys(true)); + assertTrue(root.childrenMap().isEmpty()); + assertTrue(view.contains("")); + assertNotNull(view.getConfigurationSection("")); + } + + @Test void listsUseBukkitCompatibleElementConversions() { + ConfigurationNode root = BasicConfigurationNode.root(); + root.node("list").raw(Arrays.asList("2", 3, 4.75, true, Map.of("nested", 1), List.of("nested"), "bad", " 5")); + ConfigView view = new ConfigurateConfigView(root); + assertEquals(List.of("2", "3", "4.75", "true", "bad", " 5"), view.getStringList("list")); + assertEquals(List.of(2, 3, 4), view.getIntegerList("list")); + List copy = view.getStringList("list"); + copy.clear(); + assertEquals(6, view.getStringList("list").size()); + } + + @Test void sectionsKeysAndLiteralSegmentsAreDistinct() { + ConfigurationNode root = BasicConfigurationNode.root(); + root.node("sites", "some.site", "reward").raw("say voted"); + root.node("sites", "other").raw("url"); + ConfigurateConfigView view = new ConfigurateConfigView(root); + assertNull(view.getConfigurationSection("sites.some.site")); + assertEquals("say voted", view.at("sites", "some.site").getString("reward", "")); + assertEquals(Set.of("sites"), view.getKeys(false)); + assertEquals(Set.of("sites", "sites.some.site", "sites.some.site.reward", "sites.other"), view.getKeys(true)); + assertNull(view.getConfigurationSection("sites.other")); + assertEquals("say voted", new ConfigurateConfigView(root, '/').getString("sites/some.site/reward", "")); + } + + @Test void numericYamlKeysCanBeReadUsingStringPaths() throws Exception { + ConfigurationNode root = YamlConfigurationLoader.builder().buildAndLoadString("rewards:\n 10:\n command: hello\n"); + ConfigurateConfigView view = new ConfigurateConfigView(root); + assertEquals("hello", view.getString("rewards.10.command", "")); + assertEquals(Set.of("10"), view.getConfigurationSection("rewards").getKeys(false)); + } + + static class Values { + @ConfigDataString(path="message", secondPath="old.message") String message="initial"; + @ConfigDataInt(path="votes") int votes=3; + @ConfigDataBoolean(path="enabled", secondPath="disabled", secondPathInvert=true) boolean enabled; + @ConfigDataListString(path="commands", secondPath="old.commands") ArrayList commands=new ArrayList<>(List.of("default")); + @ConfigDataConfigurationSection(path="rewards") ConfigView rewards; + @ConfigDataKeys(path="rewards") Set keys; + @ConfigDataParsedDuration(path="delay") ParsedDuration delay; + } + + @Test void realYamlFeedsTheSharedBinderWithoutBukkit() throws Exception { + ConfigurationNode root = YamlConfigurationLoader.builder().buildAndLoadString(""" + old: + message: thanks + commands: [say voted] + votes: 0 + disabled: false + commands: [] + rewards: + first: command + delay: 3s + """); + Values target = new Values(); + new AnnotationBinder().load(new ConfigurateConfigView(root), target); + assertEquals("thanks", target.message); + assertEquals(0, target.votes); + assertTrue(target.enabled); + assertEquals(List.of("say voted"), target.commands); + assertEquals("command", target.rewards.getString("first", "")); + assertEquals(Set.of("first"), target.keys); + assertEquals(3000L, target.delay.getMillis()); + assertThrows(ClassNotFoundException.class, () -> Class.forName("org.bukkit.configuration.ConfigurationSection")); + } + + @Test void adapterRejectsNullArguments() { + assertThrows(NullPointerException.class, () -> new ConfigurateConfigView(null)); + ConfigurateConfigView view=new ConfigurateConfigView(BasicConfigurationNode.root()); + assertThrows(NullPointerException.class, () -> view.getInt(null, 1)); + } +} diff --git a/simpleapi-configurate/src/test/java/com/bencodez/simpleapi/file/config/configurate/YamlConfigDocumentTest.java b/simpleapi-configurate/src/test/java/com/bencodez/simpleapi/file/config/configurate/YamlConfigDocumentTest.java new file mode 100644 index 00000000..5dd4a674 --- /dev/null +++ b/simpleapi-configurate/src/test/java/com/bencodez/simpleapi/file/config/configurate/YamlConfigDocumentTest.java @@ -0,0 +1,217 @@ +package com.bencodez.simpleapi.file.config.configurate; + +import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assumptions.assumeTrue; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFileAttributeView; +import java.nio.file.attribute.PosixFilePermission; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import com.bencodez.simpleapi.file.config.ConfigEditor; +import com.bencodez.simpleapi.file.config.ConfigSnapshot; + +class YamlConfigDocumentTest { + @TempDir Path directory; + private Path path() { return directory.resolve("votes.yml"); } + private YamlConfigDocument initialized() throws Exception { + Files.writeString(path(), "points: 7\nunknown: preserve\n"); + return YamlConfigDocument.open(path()); + } + + @Test void readingMissingFileDoesNotCreateIt() throws Exception { + YamlConfigDocument document=YamlConfigDocument.open(path()); + assertEquals("missing", document.snapshot().revision()); + document.reload(); + assertFalse(Files.exists(path())); + assertEquals(Set.of(), document.snapshot().view().getKeys(false)); + } + @Test void persistsEditsAndReloadsWithStableRevisions() throws Exception { + YamlConfigDocument document=initialized(); + ConfigSnapshot before=document.snapshot(); + ConfigSnapshot after=document.update(before.revision(), e -> e.set("points", 8)); + assertEquals(7, before.view().getInt("points", -1)); + assertEquals(8, after.view().getInt("points", -1)); + assertEquals("preserve", after.view().getString("unknown", "")); + assertNotEquals(before.revision(), after.revision()); + assertEquals(after.revision(), YamlConfigDocument.open(path()).snapshot().revision()); + assertEquals(8, document.reload().view().getInt("points", -1)); + try (var children=Files.list(directory)) { assertEquals(List.of(path()), children.toList()); } + } + @Test void createsMissingFileOnlyWhenExplicitlyUpdated() throws Exception { + YamlConfigDocument document=YamlConfigDocument.open(path()); + document.update("missing", e -> e.set("rewards.commands", List.of("say voted"))); + assertTrue(Files.exists(path())); + assertEquals(List.of("say voted"), document.reload().view().getStringList("rewards.commands")); + } + @Test void malformedReloadPreservesLastGoodAndCannotBeOverwrittenByOldRevision() throws Exception { + YamlConfigDocument document=initialized(); + ConfigSnapshot before=document.snapshot(); + String corrupt="points: [unterminated"; + Files.writeString(path(), corrupt); + assertThrows(IOException.class, document::reload); + assertEquals(before.revision(), document.snapshot().revision()); + assertEquals(7, document.snapshot().view().getInt("points", -1)); + assertThrows(IOException.class, () -> document.update(before.revision(), e -> e.set("points", 9))); + assertEquals(corrupt, Files.readString(path())); + } + @Test void rejectsStaleInMemoryRevision() throws Exception { + YamlConfigDocument document=initialized(); + String before=document.snapshot().revision(); + document.update(before, e -> e.set("points", 8)); + assertThrows(IOException.class, () -> document.update(before, e -> e.set("points", 9))); + assertEquals(8, document.snapshot().view().getInt("points", -1)); + } + @Test void rejectsExternalEditAndRequiresExplicitReload() throws Exception { + YamlConfigDocument document=initialized(); + Files.writeString(path(), "points: 50\n"); + assertThrows(IOException.class, () -> document.update(document.snapshot().revision(), e -> e.set("points", 9))); + assertEquals("points: 50\n", Files.readString(path())); + assertEquals(50, document.reload().view().getInt("points", -1)); + } + @Test void detectsFileCreatedSinceMissingSnapshot() throws Exception { + YamlConfigDocument document=YamlConfigDocument.open(path()); + Files.writeString(path(), ""); + assertThrows(IOException.class, () -> document.update("missing", e -> e.set("points", 1))); + assertEquals("", Files.readString(path())); + } + @Test void editExceptionDiscardsPrivateCopy() throws Exception { + YamlConfigDocument document=initialized(); + String bytes=Files.readString(path()); + String revision=document.snapshot().revision(); + assertThrows(IllegalArgumentException.class, () -> document.update(revision, e -> { + e.set("points", 99); + throw new IllegalArgumentException("cancel"); + })); + assertEquals(bytes, Files.readString(path())); + assertEquals(revision, document.snapshot().revision()); + assertEquals(7, document.snapshot().view().getInt("points", -1)); + } + @Test void externalEditDuringCallbackIsNotOverwritten() throws Exception { + YamlConfigDocument document=initialized(); + assertThrows(IOException.class, () -> document.update(document.snapshot().revision(), e -> { + e.set("points", 99); + try { Files.writeString(path(), "points: 42\n"); } catch (IOException failure) { throw new RuntimeException(failure); } + })); + assertEquals("points: 42\n", Files.readString(path())); + assertEquals(7, document.snapshot().view().getInt("points", -1)); + } + @Test void editorsAndCallerCollectionsCannotMutatePublishedState() throws Exception { + YamlConfigDocument document=initialized(); + AtomicReference retained=new AtomicReference<>(); + List list=new ArrayList<>(List.of("say voted")); + document.update(document.snapshot().revision(), e -> { retained.set(e); e.set("commands", list); }); + list.add("unwanted"); + assertThrows(IllegalStateException.class, () -> retained.get().set("points", 123)); + assertEquals(List.of("say voted"), document.snapshot().view().getStringList("commands")); + } + @Test void rejectsNestedWritesAndReloads() throws Exception { + YamlConfigDocument document=initialized(); + assertThrows(IllegalStateException.class, () -> document.update(document.snapshot().revision(), e -> { + try { document.reload(); } catch (IOException failure) { throw new RuntimeException(failure); } + })); + assertThrows(IllegalStateException.class, () -> document.update(document.snapshot().revision(), e -> { + try { document.update(document.snapshot().revision(), next -> next.set("points", 10)); } + catch (IOException failure) { throw new RuntimeException(failure); } + })); + assertEquals(7, document.snapshot().view().getInt("points", -1)); + } + @Test void rejectsEditorWritesOnOtherThreads() throws Exception { + YamlConfigDocument document=initialized(); + AtomicReference observed=new AtomicReference<>(); + document.update(document.snapshot().revision(), e -> { + Thread thread=new Thread(() -> { try { e.set("points", 99); } catch (Throwable failure) { observed.set(failure); } }); + thread.start(); + try { thread.join(5000); } catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); throw new RuntimeException(interrupted); } + assertFalse(thread.isAlive()); + }); + assertInstanceOf(IllegalStateException.class, observed.get()); + assertEquals(7, document.snapshot().view().getInt("points", -1)); + } + @Test void boundsInputAndOutputWithoutTruncatingOriginal() throws Exception { + Files.writeString(path(), "points: 7\n"); + YamlConfigDocument document=YamlConfigDocument.open(path(), 128); + String before=Files.readString(path()); + assertThrows(IOException.class, () -> document.update(document.snapshot().revision(), e -> e.set("large", "x".repeat(512)))); + assertEquals(before, Files.readString(path())); + Files.writeString(path(), "x".repeat(129)); + assertThrows(IOException.class, document::reload); + assertEquals(7, document.snapshot().view().getInt("points", -1)); + } + @Test void malformedUtf8DoesNotReplaceLastGood() throws Exception { + YamlConfigDocument document=initialized(); + Files.write(path(), new byte[] {(byte)0xc3, 0x28}); + assertThrows(IOException.class, document::reload); + assertEquals(7, document.snapshot().view().getInt("points", -1)); + } + @Test void refusesSymlinkAndDirectoryTargets() throws Exception { + Path actual=directory.resolve("actual.yml"); + Files.writeString(actual, "points: 4\n"); + Files.createSymbolicLink(path(), actual); + assertThrows(IOException.class, () -> YamlConfigDocument.open(path())); + assertEquals("points: 4\n", Files.readString(actual)); + assertThrows(IOException.class, () -> YamlConfigDocument.open(directory)); + } + @Test void refusesSymlinkIntroducedAfterOpen() throws Exception { + YamlConfigDocument document=initialized(); + Path other=directory.resolve("other.yml"); + Files.writeString(other, "do not overwrite\n"); + Files.delete(path()); + Files.createSymbolicLink(path(), other); + assertThrows(IOException.class, () -> document.update(document.snapshot().revision(), e -> e.set("points", 99))); + assertEquals("do not overwrite\n", Files.readString(other)); + } + @Test void preservesPosixModeAndDefaultsNewFilesToOwnerOnly() throws Exception { + assumeTrue(Files.getFileAttributeView(directory, PosixFileAttributeView.class) != null); + YamlConfigDocument document=initialized(); + Set mode=Set.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE, PosixFilePermission.GROUP_READ); + Files.setPosixFilePermissions(path(), mode); + document.update(document.snapshot().revision(), e -> e.set("points", 8)); + assertEquals(mode, Files.getPosixFilePermissions(path())); + Path fresh=directory.resolve("new.yml"); + YamlConfigDocument.open(fresh).update("missing", e -> e.set("points", 1)); + assertEquals(Set.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE), Files.getPosixFilePermissions(fresh)); + } + @Test void supportsLiteralAndNumericKeysWithoutCreatingDuplicateSections() throws Exception { + Files.writeString(path(), "rewards:\n 10:\n command: before\n"); + YamlConfigDocument document=YamlConfigDocument.open(path()); + document.update(document.snapshot().revision(), e -> { e.set("rewards.10.command", "after"); e.setAt(Map.of("command", "literal"), "some.site"); }); + assertEquals(Set.of("10"), document.reload().view().getConfigurationSection("rewards").getKeys(false)); + ConfigurateConfigView view=(ConfigurateConfigView)document.snapshot().view(); + assertEquals("after", view.getString("rewards.10.command", "")); + assertEquals("literal", view.at("some.site").getString("command", "")); + } + @Test void rejectsCyclesDeepTreesNativeObjectsAndOversizedNodeTrees() throws Exception { + YamlConfigDocument document=initialized(); + Map cycle=new LinkedHashMap<>();cycle.put("cycle", cycle); + assertThrows(IllegalArgumentException.class, () -> document.update(document.snapshot().revision(), e -> e.set("cycle", cycle))); + Object tree="leaf"; + for (int i=0;i<70;i++) tree=Map.of("child",tree); + Object tooDeep=tree; + assertThrows(IllegalArgumentException.class, () -> document.update(document.snapshot().revision(), e -> e.set("deep", tooDeep))); + assertThrows(IllegalArgumentException.class, () -> document.update(document.snapshot().revision(), e -> e.set("native", new Object()))); + assertThrows(IllegalArgumentException.class, () -> document.update(document.snapshot().revision(), e -> e.set("many", Collections.nCopies(100_001, 1)))); + assertEquals(7, document.snapshot().view().getInt("points", -1)); + } + @Test void nullRemovesOnlyTheSelectedValue() throws Exception { + YamlConfigDocument document=initialized(); + document.update(document.snapshot().revision(), e -> e.remove("points")); + assertFalse(document.reload().view().contains("points")); + assertEquals("preserve", document.snapshot().view().getString("unknown", "")); + } + @Test void rejectsMissingParentInvalidLimitAndScalarRoot() throws Exception { + assertThrows(IOException.class, () -> YamlConfigDocument.open(directory.resolve("missing/file.yml"))); + assertFalse(Files.exists(directory.resolve("missing"))); + assertThrows(IllegalArgumentException.class, () -> YamlConfigDocument.open(path(), 0)); + Files.writeString(path(), "[1, 2, 3]\n"); + assertThrows(IOException.class, () -> YamlConfigDocument.open(path())); + } +} diff --git a/simpleapi-core/pom.xml b/simpleapi-core/pom.xml new file mode 100644 index 00000000..d0f6e73b --- /dev/null +++ b/simpleapi-core/pom.xml @@ -0,0 +1,50 @@ + + + 4.0.0 + com.bencodezsimpleapi-parent1.0.2-SNAPSHOT + simpleapi-core + simpleapi-core + + org.projectlomboklomboktrue + org.junit.jupiterjunit-jupiter + + + + + org.apache.maven.pluginsmaven-resources-plugin + stage-existing-shared-sourcesgenerate-sourcescopy-resources + ${project.build.directory}/generated-sources/shared + ${project.basedir}/../SimpleAPI/src/main/javafalse + com/bencodez/simpleapi/file/config/*.java + com/bencodez/simpleapi/file/annotation/AnnotationBinder.java + com/bencodez/simpleapi/file/annotation/ConfigData*.java + com/bencodez/simpleapi/time/ParsedDuration.java + com/bencodez/simpleapi/debug/DebugLevel.java + com/bencodez/simpleapi/sql/Column.java + com/bencodez/simpleapi/sql/DataType.java + com/bencodez/simpleapi/sql/data/*.java + + + stage-existing-headless-testsgenerate-test-sourcescopy-resources + ${project.build.directory}/generated-test-sources/shared + ${project.basedir}/../SimpleAPI/src/test/javafalse + com/bencodez/simpleapi/tests/ParsedDurationTest.java + com/bencodez/simpleapi/tests/file/config/AnnotationBinderHeadlessTest.java + com/bencodez/simpleapi/tests/file/config/HeadlessBindingFixture.java + + + + org.codehaus.mojobuild-helper-maven-plugin + existing-shared-sourcesgenerate-sourcesadd-source + ${project.build.directory}/generated-sources/shared + + existing-headless-testsgenerate-test-sourcesadd-test-source + ${project.build.directory}/generated-test-sources/shared + + + org.apache.maven.pluginsmaven-source-plugin + sourcespackagejar-no-fork + + + diff --git a/simpleapi-core/src/test/java/com/bencodez/simpleapi/core/CoreClasspathTest.java b/simpleapi-core/src/test/java/com/bencodez/simpleapi/core/CoreClasspathTest.java new file mode 100644 index 00000000..b2526597 --- /dev/null +++ b/simpleapi-core/src/test/java/com/bencodez/simpleapi/core/CoreClasspathTest.java @@ -0,0 +1,17 @@ +package com.bencodez.simpleapi.core; + +import static org.junit.jupiter.api.Assertions.*; +import org.junit.jupiter.api.Test; +import com.bencodez.simpleapi.sql.data.DataValueInt; +import com.bencodez.simpleapi.time.ParsedDuration; +import java.util.concurrent.TimeUnit; + +class CoreClasspathTest { + @Test void existingValueAndDurationClassesRemainUsableWithoutPlatforms() { + assertEquals(7, new DataValueInt(7).getInt()); + assertEquals(5000L, ParsedDuration.parse("5s", TimeUnit.SECONDS).getMillis()); + assertThrows(ClassNotFoundException.class, () -> Class.forName("org.bukkit.Bukkit")); + assertThrows(ClassNotFoundException.class, () -> Class.forName("net.md_5.bungee.api.ProxyServer")); + assertThrows(ClassNotFoundException.class, () -> Class.forName("com.velocitypowered.api.proxy.ProxyServer")); + } +} diff --git a/simpleapi-sql/pom.xml b/simpleapi-sql/pom.xml new file mode 100644 index 00000000..ce91646c --- /dev/null +++ b/simpleapi-sql/pom.xml @@ -0,0 +1,42 @@ + + + 4.0.0 + com.bencodezsimpleapi-parent1.0.2-SNAPSHOT + simpleapi-sql + simpleapi-sql + + org.projectlomboklomboktrue + org.junit.jupiterjunit-jupiter + com.bencodezsimpleapi-core${project.version} + com.zaxxerHikariCP7.0.2 + com.bencodezsimpleapi-configurate${project.version}test + + + + org.apache.maven.pluginsmaven-resources-plugin + stage-existing-shared-sourcesgenerate-sourcescopy-resources + ${project.build.directory}/generated-sources/shared + ${project.basedir}/../SimpleAPI/src/main/javafalse + com/bencodez/simpleapi/sql/mysql/AbstractSqlTable.java + com/bencodez/simpleapi/sql/mysql/ConnectionManager.java + com/bencodez/simpleapi/sql/mysql/DbType.java + com/bencodez/simpleapi/sql/mysql/MySQL.java + com/bencodez/simpleapi/sql/mysql/config/MysqlConfig.java + com/bencodez/simpleapi/sql/mysql/config/MysqlConfigView.java + com/bencodez/simpleapi/sql/mysql/queries/*.java + + + + + org.codehaus.mojobuild-helper-maven-plugin + existing-shared-sourcesgenerate-sourcesadd-source + ${project.build.directory}/generated-sources/shared + + + + org.apache.maven.pluginsmaven-source-plugin + sourcespackagejar-no-fork + + + diff --git a/simpleapi-sql/src/test/java/com/bencodez/simpleapi/sql/mysql/SharedSqlTest.java b/simpleapi-sql/src/test/java/com/bencodez/simpleapi/sql/mysql/SharedSqlTest.java new file mode 100644 index 00000000..4bba72a2 --- /dev/null +++ b/simpleapi-sql/src/test/java/com/bencodez/simpleapi/sql/mysql/SharedSqlTest.java @@ -0,0 +1,39 @@ +package com.bencodez.simpleapi.sql.mysql; + +import static org.junit.jupiter.api.Assertions.*; +import org.junit.jupiter.api.Test; +import org.spongepowered.configurate.BasicConfigurationNode; +import com.bencodez.simpleapi.file.config.configurate.ConfigurateConfigView; +import com.bencodez.simpleapi.sql.mysql.config.MysqlConfigView; +import com.bencodez.simpleapi.sql.mysql.queries.Query; + +class SharedSqlTest { + @Test void readsExistingDefaultsWithoutAPlatformOrOpeningConnections() { + MysqlConfigView config=new MysqlConfigView(new ConfigurateConfigView(BasicConfigurationNode.root())); + assertEquals(1, config.getMaxThreads()); + assertEquals(-1, config.getLifeTime()); + assertEquals(2, config.getMinimumIdle()); + assertEquals(50_000, config.getConnectionTimeout()); + assertEquals(DbType.MYSQL, config.getDbType()); + assertFalse(config.isUseSSL()); + assertFalse(config.hasTableNameSet()); + assertThrows(ClassNotFoundException.class, () -> Class.forName("org.bukkit.Bukkit")); + } + @Test void preservesMariaDbFallbackAndExplicitDbSelection() { + var node=BasicConfigurationNode.root(); + node.node("UseMariaDB").raw(true); + node.node("MaxConnections").raw(0); + node.node("Name").raw("votes"); + MysqlConfigView maria=new MysqlConfigView(new ConfigurateConfigView(node)); + assertEquals(DbType.MARIADB, maria.getDbType()); + assertEquals(1, maria.getMaxThreads()); + assertEquals("votes", maria.getTableName()); + node.node("DbType").raw("POSTGRESQL"); + assertEquals(DbType.fromString("POSTGRESQL"), new MysqlConfigView(new ConfigurateConfigView(node)).getDbType()); + } + @Test void loadsTheExistingQueryAndConnectionApiWithoutBukkit() { + assertDoesNotThrow(() -> ConnectionManager.class.getDeclaredMethods()); + assertDoesNotThrow(() -> AbstractSqlTable.class.getDeclaredMethods()); + assertDoesNotThrow(() -> Query.class.getDeclaredMethods()); + } +} diff --git a/tools/ConfigParityProbe.java b/tools/ConfigParityProbe.java new file mode 100644 index 00000000..4f5d70b4 --- /dev/null +++ b/tools/ConfigParityProbe.java @@ -0,0 +1,54 @@ +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Objects; +import org.bukkit.configuration.MemoryConfiguration; +import org.spongepowered.configurate.BasicConfigurationNode; +import com.bencodez.simpleapi.file.config.configurate.ConfigurateConfigView; +import com.bencodez.simpleapi.sql.mysql.config.MysqlConfig; +import com.bencodez.simpleapi.sql.mysql.config.MysqlConfigSpigot; +import com.bencodez.simpleapi.sql.mysql.config.MysqlConfigView; + +/** Deliberately has both platforms' test inputs; never a native runtime dependency. */ +public final class ConfigParityProbe { + private static int checks; + private static void equal(Object expected,Object actual) { + checks++; + if (!Objects.equals(expected,actual)) throw new AssertionError("Configuration parity failed: expected="+expected+", actual="+actual); + } + public static void main(String[] arguments) throws Exception { + Object[] values={null,"", "text", "true", "12", false, true, 0, -2, 5L, 4_000_000_000L, 1.25, List.of(), List.of("1", 2, 3.75, true, "bad")}; + for (Object value:values) { + var bukkit=new MemoryConfiguration(); + var node=BasicConfigurationNode.root(); + bukkit.set("value",value); node.node("value").raw(value); + var view=new ConfigurateConfigView(node); + equal(bukkit.contains("value"),view.contains("value")); + equal(bukkit.getString("value","fallback"),view.getString("value","fallback")); + equal(bukkit.getBoolean("value",false),view.getBoolean("value",false)); + equal(bukkit.getBoolean("value",true),view.getBoolean("value",true)); + equal(bukkit.getInt("value",7),view.getInt("value",7)); + equal(bukkit.getLong("value",9L),view.getLong("value",9L)); + equal(bukkit.getDouble("value",2.5),view.getDouble("value",2.5)); + equal(bukkit.getStringList("value"),view.getStringList("value")); + equal(bukkit.getIntegerList("value"),view.getIntegerList("value")); + } + for (boolean populated:new boolean[]{false,true}) { + var bukkit=new MemoryConfiguration();var node=BasicConfigurationNode.root(); + if (populated) { + var data=new LinkedHashMap(); + data.put("Host","localhost");data.put("Port",3306);data.put("Username","fixture");data.put("Password","synthetic-fixture"); + data.put("Database","votes");data.put("Prefix","test_");data.put("Name","users");data.put("MaxConnections",0); + data.put("UseMariaDB",true);data.put("UseSSL",true);data.put("PoolName","fixture");data.put("ConnectionTimeout",1000); + for (var entry:data.entrySet()) { bukkit.set(entry.getKey(),entry.getValue()); node.node(entry.getKey()).raw(entry.getValue()); } + } + var oldConfig=new MysqlConfigSpigot(bukkit); + var sharedConfig=new MysqlConfigView(new ConfigurateConfigView(node)); + for (Method method:MysqlConfig.class.getDeclaredMethods()) + if (method.getParameterCount()==0 && (method.getName().startsWith("get") || method.getName().startsWith("is") || method.getName().startsWith("has"))) + equal(method.invoke(oldConfig),method.invoke(sharedConfig)); + } + System.out.println("Bukkit/shared configuration parity: "+checks+" comparisons passed"); + } +} diff --git a/tools/NativeConfigSmoke.java b/tools/NativeConfigSmoke.java new file mode 100644 index 00000000..6c035fb4 --- /dev/null +++ b/tools/NativeConfigSmoke.java @@ -0,0 +1,36 @@ +import java.nio.file.Files; +import java.util.List; +import com.bencodez.simpleapi.file.annotation.AnnotationBinder; +import com.bencodez.simpleapi.file.annotation.ConfigDataInt; +import com.bencodez.simpleapi.file.annotation.ConfigDataListString; +import com.bencodez.simpleapi.file.config.configurate.YamlConfigDocument; +import com.bencodez.simpleapi.sql.mysql.config.MysqlConfigView; + +/** Consumer compiled against packaged artifacts, not reactor target/classes. */ +public final class NativeConfigSmoke { + static final class Options { + @ConfigDataInt(path="PointsOnVote") int points; + @ConfigDataListString(path="Rewards.Commands") java.util.ArrayList commands; + } + public static void main(String[] arguments) throws Exception { + var directory=Files.createTempDirectory("simpleapi-native-smoke-"); + var file=directory.resolve("VoteSites.yml"); + try { + var document=YamlConfigDocument.open(file); + document.update("missing", edit -> { + edit.set("PointsOnVote", 3); + edit.set("Rewards.Commands", List.of("give player minecraft:diamond")); + edit.set("MySQL.Host", "localhost"); + }); + var snapshot=YamlConfigDocument.open(file).snapshot(); + var options=new Options(); + new AnnotationBinder().load(snapshot.view(), options); + if (options.points != 3 || options.commands.size() != 1) throw new AssertionError("Native binding failed"); + var sql=new MysqlConfigView(snapshot.view().getConfigurationSection("MySQL")); + if (!"localhost".equals(sql.getHostName()) || sql.getMaxThreads() != 1) throw new AssertionError("Native SQL configuration failed"); + try { Class.forName("org.bukkit.Bukkit"); throw new AssertionError("Bukkit was available"); } + catch (ClassNotFoundException expected) { } + System.out.println("Packaged native consumer: YAML, binder, persistence and SQL configuration passed"); + } finally { Files.deleteIfExists(file); Files.deleteIfExists(directory); } + } +} diff --git a/tools/SharedArtifactProbe.java b/tools/SharedArtifactProbe.java new file mode 100644 index 00000000..3b99cec6 --- /dev/null +++ b/tools/SharedArtifactProbe.java @@ -0,0 +1,41 @@ +import java.net.URLClassLoader; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.jar.JarFile; + +/** Link every published SimpleAPI class using only the selected runtime JARs. */ +public final class SharedArtifactProbe { + public static void main(String[] arguments) throws Exception { + var urls=new ArrayList(); + for (String argument:arguments) urls.add(Path.of(argument).toUri().toURL()); + int checked=0; + try (var loader=new URLClassLoader(urls.toArray(java.net.URL[]::new), ClassLoader.getPlatformClassLoader())) { + for (String forbidden:new String[]{"org.bukkit.Bukkit", "net.minecraft.server.MinecraftServer", "net.md_5.bungee.api.ProxyServer", "com.velocitypowered.api.proxy.ProxyServer"}) { + try { loader.loadClass(forbidden); throw new AssertionError("Platform leaked into native runtime: " + forbidden); } + catch (ClassNotFoundException expected) { } + } + var names=new LinkedHashSet(); + for (String argument:arguments) { + try (var jar=new JarFile(argument)) { + for (var entry:jar.stream().toList()) { + String name=entry.getName(); + if (name.startsWith("com/bencodez/simpleapi/") && name.endsWith(".class")) + names.add(name.substring(0,name.length()-6).replace('/', '.')); + } + } + } + if (names.isEmpty()) throw new AssertionError("No shared classes found"); + for (String name:names) { + Class type=Class.forName(name, false, loader); + type.getDeclaredConstructors(); type.getDeclaredFields(); type.getDeclaredMethods(); + type.getGenericSuperclass(); type.getGenericInterfaces(); + for (var method:type.getDeclaredMethods()) { + method.getGenericReturnType(); method.getGenericParameterTypes(); method.getGenericExceptionTypes(); + } + checked++; + } + } + System.out.println("Packaged headless linkage: " + checked + " classes passed"); + } +} diff --git a/tools/verify-shared-artifacts.py b/tools/verify-shared-artifacts.py new file mode 100644 index 00000000..0773d904 --- /dev/null +++ b/tools/verify-shared-artifacts.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +"""Check packaged class boundaries and compile/run a downstream native consumer.""" +from pathlib import Path +import os +import subprocess +import tempfile +import zipfile + +ROOT=Path(__file__).resolve().parents[1] +MODULES=("simpleapi-core", "simpleapi-configurate", "simpleapi-sql") +FORBIDDEN=(b"org/bukkit/", b"org/spigotmc/", b"io/papermc/paper/", b"net/minecraft/", + b"net/fabricmc/", b"net/minecraftforge/", b"net/neoforged/", b"net/md_5/bungee/", + b"com/velocitypowered/api/") + +def main(): + jars=[] + for module in MODULES: + target=ROOT/module/"target" + candidates=[p for p in target.glob(f"{module}-*.jar") if not p.name.endswith(("-sources.jar","-javadoc.jar"))] + if len(candidates)!=1: raise RuntimeError(f"Expected one freshly built artifact for {module}") + jar=candidates[0] + with zipfile.ZipFile(jar) as archive: + classes=[n for n in archive.namelist() if n.endswith(".class")] + if not classes: raise RuntimeError(f"Empty artifact: {jar}") + for name in classes: + data=archive.read(name) + if any(prefix in data or prefix.decode() in name for prefix in FORBIDDEN): + raise RuntimeError(f"Platform dependency in {module}: {name}") + sources=jar.with_name(jar.stem+"-sources.jar") + if not sources.is_file(): raise RuntimeError(f"Missing source artifact: {sources}") + # Copied at build time from the existing implementation, never maintained twice. + for source in (target/"generated-sources/shared").rglob("*.java"): + relative=source.relative_to(target/"generated-sources/shared") + if source.read_bytes()!=(ROOT/"SimpleAPI/src/main/java"/relative).read_bytes(): + raise RuntimeError(f"Shared source drift: {relative}") + jars.append(jar) + jars.extend(sorted((target/"runtime-deps").glob("*.jar"))) + jars=list(dict.fromkeys(p.resolve() for p in jars)) + published_classes={} + for jar in jars: + with zipfile.ZipFile(jar) as archive: + for name in archive.namelist(): + if name.startswith("com/bencodez/simpleapi/") and name.endswith(".class"): + data=archive.read(name) + if name in published_classes and published_classes[name]!=data: + raise RuntimeError(f"Conflicting shared class bytes: {name} in {jar}") + published_classes[name]=data + if any(name.startswith(prefix.decode()) for prefix in FORBIDDEN): + raise RuntimeError(f"Platform classes in native runtime dependency: {jar.name}: {name}") + subprocess.run(["java",str(ROOT/"tools/SharedArtifactProbe.java"),*[str(p) for p in jars]],check=True) + classpath=os.pathsep.join(str(p) for p in jars) + with tempfile.TemporaryDirectory(prefix="simpleapi-consumer-") as output: + subprocess.run(["javac","--release","21","-proc:none","-cp",classpath,"-d",output,str(ROOT/"tools/NativeConfigSmoke.java")],check=True) + subprocess.run(["java","-cp",output+os.pathsep+classpath,"NativeConfigSmoke"],check=True) + legacy_cp_file=ROOT/"SimpleAPI/target/compatibility-classpath.txt" + legacy_cp=legacy_cp_file.read_text().strip() + parity_cp=classpath+os.pathsep+str(ROOT/"SimpleAPI/target/SimpleAPI.jar")+os.pathsep+legacy_cp + subprocess.run(["javac","--release","21","-proc:none","-cp",parity_cp,"-d",output,str(ROOT/"tools/ConfigParityProbe.java")],check=True) + subprocess.run(["java","-cp",output+os.pathsep+parity_cp,"ConfigParityProbe"],check=True) + print("Shared binary/source artifacts and runtime boundaries passed") + +if __name__=="__main__": main() From d08f86142f391b26c3918b2cbfb96e2ee1f3f0af Mon Sep 17 00:00:00 2001 From: Ben Date: Mon, 7 Sep 2026 16:58:41 -0600 Subject: [PATCH 2/6] Clarify shaded SQL integration and merged HTTP transport boundaries --- docs/shared-libraries.md | 37 ++++++++++++++++++++++++------------- 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/docs/shared-libraries.md b/docs/shared-libraries.md index 6e8a1fb8..19a3410e 100644 --- a/docs/shared-libraries.md +++ b/docs/shared-libraries.md @@ -34,15 +34,24 @@ coordinates. **Do not package both representations of the same classes.** A native application uses the shared artifacts and never the full `simpleapi` artifact. A Bukkit -application uses the full distribution. When a future AdvancedCore common module -brings shared artifacts transitively into a Bukkit distribution, exclude those -three dependencies from that edge and satisfy their classes from the aligned full -SimpleAPI dependency. Keep versions aligned. Existing AdvancedCore/VotingPlugin -builds do not need this exclusion today because they do not consume the new modules. - -The thin SQL artifact intentionally uses unrelocated HikariCP. Existing full -SimpleAPI shading remains unchanged. A native mod packager must package its actual -runtime dependency graph using the target loader's supported mechanism. +application retains the full distribution. Future AdvancedCore common-module +integration must choose one representation of each SimpleAPI class in the final +Bukkit JAR, keep versions aligned, and verify the final shaded artifact's linkage. +Existing AdvancedCore/VotingPlugin builds do not consume the new thin modules and +need no dependency changes in this PR. + +The thin SQL artifact intentionally uses unrelocated HikariCP, whereas the full +SimpleAPI distribution already relocates HikariCP. Some existing SQL public +signatures expose Hikari types. Therefore excluding the thin dependencies in favor +of the full JAR is not sufficient by itself for common code compiled against those +signatures: the final Bukkit packaging must relocate the common callers and the +provided SQL implementations consistently. Prefer JDBC/JDK types at new shared +boundaries and add a final packaged Bukkit linkage test when making that consumer +change. This PR does not claim that future mixed packaging is already validated. + +A native mod packager must include its actual runtime dependency graph using the +target loader's supported mechanism. No changes to existing full-JAR shading are +made here. ## Native configuration example @@ -149,8 +158,8 @@ Source staging is checked byte-for-byte against the one maintained implementatio The same workflow installs the exact candidate in an isolated Maven repository, builds pinned AdvancedCore and VotingPlugin fixtures, verifies their dependency classpath paths and checks that installed candidate JAR hashes were not replaced. -It never merges, publishes releases, contacts production, or submits a dependency -graph on a pull request. Update the fixture SHAs deliberately when testing newer +It never merges, publishes releases, modifies production services, or submits a +dependency graph on a pull request. Update the fixture SHAs deliberately when testing newer consumer source. SQL tests here cover configuration/linkage, not a live database matrix. Live Minecraft/Folia/Forge/Fabric smoke tests remain application-level work. @@ -163,5 +172,7 @@ player, messaging, item, inventory and mixed `ArrayUtils` APIs remain untouched; their game operations belong behind adapters during that extraction. Do not replace entity-aware scheduling with a generic global-thread executor. -The active HTTP transport PR #73 is deliberately independent. It is not copied or -reworked here. Its final shared packaging can be added after that transport lands. +The separate HTTP transport work from PR #73 is not copied or reworked here. It +merged into main while this change was being validated; the PR merge-ref build +tests compatibility with it. Packaging that transport as a thin native dependency +is a separate follow-up, not part of these configuration/data/SQL artifacts. From 719c3d617cc95110b089b5f233915ecc1fb32800 Mon Sep 17 00:00:00 2001 From: Ben Date: Mon, 7 Sep 2026 18:01:14 -0600 Subject: [PATCH 3/6] Limit shared-library CI to SimpleAPI and remove pinned downstream builds --- .github/workflows/shared-libraries.yml | 67 ++++---------------------- 1 file changed, 10 insertions(+), 57 deletions(-) diff --git a/.github/workflows/shared-libraries.yml b/.github/workflows/shared-libraries.yml index d4e54375..80ae9585 100644 --- a/.github/workflows/shared-libraries.yml +++ b/.github/workflows/shared-libraries.yml @@ -1,10 +1,10 @@ -name: Shared libraries and downstream compatibility +name: Shared libraries on: pull_request: branches: [main] push: - branches: [main, codex/simpleapi-shared-libraries] + branches: [main] workflow_dispatch: permissions: @@ -27,73 +27,26 @@ jobs: java-version: '21' distribution: temurin cache: maven - # Pinned compatibility fixtures, not moving branches or a release/deployment job. - - uses: actions/checkout@v4 - with: - repository: BenCodez/AdvancedCore - ref: 3f66545590644d87ec45d7864d831691a07830ac - path: .compat/AdvancedCore - persist-credentials: false - - uses: actions/checkout@v4 - with: - repository: BenCodez/VotingPlugin - ref: 350caccb12053a12bb47bbe22012b875580a77d2 - path: .compat/VotingPlugin - persist-credentials: false - - name: Isolate Maven cache - shell: bash - run: | - set -euo pipefail - repo="$RUNNER_TEMP/simpleapi-candidate-m2" - mkdir -p "$repo" - if [ -d "$HOME/.m2/repository" ]; then cp -a "$HOME/.m2/repository/." "$repo/"; fi - echo "CANDIDATE_M2=$repo" >> "$GITHUB_ENV" - - name: Build shared libraries and unchanged legacy artifact - run: mvn -B -ntp -Dmaven.repo.local="$CANDIDATE_M2" clean install + # Repository-local validation only; downstream plugin builds are opt-in work. + # Install into the runner's local Maven cache, never a remote repository. + - name: Build shared libraries and legacy artifact + run: mvn -B -ntp clean install - name: Collect runtime classpaths shell: bash run: | set -euo pipefail - mvn -B -ntp -nsu -Dmaven.repo.local="$CANDIDATE_M2" -pl simpleapi-core,simpleapi-configurate,simpleapi-sql -am org.apache.maven.plugins:maven-dependency-plugin:3.8.1:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/runtime-deps - mvn -B -ntp -nsu -Dmaven.repo.local="$CANDIDATE_M2" -f SimpleAPI/pom.xml org.apache.maven.plugins:maven-dependency-plugin:3.8.1:build-classpath -Dmdep.outputFile=target/compatibility-classpath.txt - - name: Verify actual packaged artifacts and Bukkit parity + mvn -B -ntp -nsu -pl simpleapi-core,simpleapi-configurate,simpleapi-sql -am org.apache.maven.plugins:maven-dependency-plugin:3.8.1:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/runtime-deps + mvn -B -ntp -nsu -f SimpleAPI/pom.xml org.apache.maven.plugins:maven-dependency-plugin:3.8.1:build-classpath -Dmdep.outputFile=target/compatibility-classpath.txt + - name: Verify packaged artifacts and Bukkit parity run: python3 tools/verify-shared-artifacts.py - - name: Pin candidate dependency bytes - shell: bash - run: | - set -euo pipefail - candidate="$CANDIDATE_M2/com/bencodez/simpleapi/1.0.2-SNAPSHOT/simpleapi-1.0.2-SNAPSHOT.jar" - cmp SimpleAPI/target/SimpleAPI.jar "$candidate" - sha256sum "$candidate" > "$RUNNER_TEMP/simpleapi-candidate.sha256" - - name: Build AdvancedCore against candidate - shell: bash - run: | - set -euo pipefail - mvn -B -ntp -nsu -Dmaven.repo.local="$CANDIDATE_M2" -f .compat/AdvancedCore/AdvancedCore/pom.xml clean install - sha256sum -c "$RUNNER_TEMP/simpleapi-candidate.sha256" - mvn -B -ntp -nsu -Dmaven.repo.local="$CANDIDATE_M2" -f .compat/AdvancedCore/AdvancedCore/pom.xml org.apache.maven.plugins:maven-dependency-plugin:3.8.1:build-classpath -Dmdep.outputFile=target/candidate-classpath.txt - grep -F "$CANDIDATE_M2/com/bencodez/simpleapi/1.0.2-SNAPSHOT/simpleapi-1.0.2-SNAPSHOT.jar" .compat/AdvancedCore/AdvancedCore/target/candidate-classpath.txt - sha256sum "$CANDIDATE_M2/com/bencodez/advancedcore/3.8.2-SNAPSHOT/advancedcore-3.8.2-SNAPSHOT.jar" > "$RUNNER_TEMP/advancedcore-candidate.sha256" - - name: Build VotingPlugin against candidate chain - shell: bash - run: | - set -euo pipefail - mvn -B -ntp -nsu -Dmaven.repo.local="$CANDIDATE_M2" -f .compat/VotingPlugin/VotingPlugin/pom.xml clean verify - sha256sum -c "$RUNNER_TEMP/simpleapi-candidate.sha256" - sha256sum -c "$RUNNER_TEMP/advancedcore-candidate.sha256" - mvn -B -ntp -nsu -Dmaven.repo.local="$CANDIDATE_M2" -f .compat/VotingPlugin/VotingPlugin/pom.xml org.apache.maven.plugins:maven-dependency-plugin:3.8.1:build-classpath -Dmdep.outputFile=target/candidate-classpath.txt - grep -F "$CANDIDATE_M2/com/bencodez/advancedcore/3.8.2-SNAPSHOT/advancedcore-3.8.2-SNAPSHOT.jar" .compat/VotingPlugin/VotingPlugin/target/candidate-classpath.txt - - name: Preserve test reports and candidate artifacts + - name: Preserve test reports and shared artifacts if: always() uses: actions/upload-artifact@v4 with: name: shared-library-validation retention-days: 7 if-no-files-found: warn - include-hidden-files: true path: | simpleapi-*/target/*.jar simpleapi-*/target/surefire-reports/*.xml SimpleAPI/target/surefire-reports/*.xml - .compat/AdvancedCore/AdvancedCore/target/surefire-reports/*.xml - .compat/VotingPlugin/VotingPlugin/target/surefire-reports/*.xml From 78563dba6a5cbb168020d4ea65277cd229d738a7 Mon Sep 17 00:00:00 2001 From: Ben Date: Mon, 7 Sep 2026 18:01:45 -0600 Subject: [PATCH 4/6] Document repository-local shared-library validation --- docs/shared-libraries.md | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/docs/shared-libraries.md b/docs/shared-libraries.md index 19a3410e..0f512abd 100644 --- a/docs/shared-libraries.md +++ b/docs/shared-libraries.md @@ -155,12 +155,16 @@ class boundaries, and compiles a consumer against packaged JARs (not target/clas A separate probe compares actual Bukkit scalar/list and SQL configuration behavior. Source staging is checked byte-for-byte against the one maintained implementation. -The same workflow installs the exact candidate in an isolated Maven repository, -builds pinned AdvancedCore and VotingPlugin fixtures, verifies their dependency -classpath paths and checks that installed candidate JAR hashes were not replaced. -It never merges, publishes releases, modifies production services, or submits a -dependency graph on a pull request. Update the fixture SHAs deliberately when testing newer -consumer source. SQL tests here cover configuration/linkage, not a live database +The workflow validates only this repository. It does not check out AdvancedCore +or VotingPlugin, pin their commits, or assume particular plugin artifact versions. +Maven installs the current reactor artifacts into the runner's local cache solely +for these checks; nothing is published remotely. The temporary feature-branch +push trigger is removed, avoiding duplicate push/PR runs for that branch. + +Cross-repository builds remain an explicit check for significant API changes or +release preparation, not an automatic dependency of every SimpleAPI PR. The +initial downstream validation results are recorded in PR #76 as historical +verification. SQL tests here cover configuration/linkage, not a live database matrix. Live Minecraft/Folia/Forge/Fabric smoke tests remain application-level work. ## What remains for the platform port From ebd0a104e702f3cdb2978d3f9bc6d467cc16fc7c Mon Sep 17 00:00:00 2001 From: Ben Date: Mon, 7 Sep 2026 18:09:20 -0600 Subject: [PATCH 5/6] Remove optional shared-library workflow as requested --- .github/workflows/shared-libraries.yml | 52 -------------------------- 1 file changed, 52 deletions(-) delete mode 100644 .github/workflows/shared-libraries.yml diff --git a/.github/workflows/shared-libraries.yml b/.github/workflows/shared-libraries.yml deleted file mode 100644 index 80ae9585..00000000 --- a/.github/workflows/shared-libraries.yml +++ /dev/null @@ -1,52 +0,0 @@ -name: Shared libraries - -on: - pull_request: - branches: [main] - push: - branches: [main] - workflow_dispatch: - -permissions: - contents: read - -concurrency: - group: shared-libraries-${{ github.ref }} - cancel-in-progress: true - -jobs: - verify: - runs-on: ubuntu-latest - timeout-minutes: 25 - steps: - - uses: actions/checkout@v4 - with: - persist-credentials: false - - uses: actions/setup-java@v4 - with: - java-version: '21' - distribution: temurin - cache: maven - # Repository-local validation only; downstream plugin builds are opt-in work. - # Install into the runner's local Maven cache, never a remote repository. - - name: Build shared libraries and legacy artifact - run: mvn -B -ntp clean install - - name: Collect runtime classpaths - shell: bash - run: | - set -euo pipefail - mvn -B -ntp -nsu -pl simpleapi-core,simpleapi-configurate,simpleapi-sql -am org.apache.maven.plugins:maven-dependency-plugin:3.8.1:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/runtime-deps - mvn -B -ntp -nsu -f SimpleAPI/pom.xml org.apache.maven.plugins:maven-dependency-plugin:3.8.1:build-classpath -Dmdep.outputFile=target/compatibility-classpath.txt - - name: Verify packaged artifacts and Bukkit parity - run: python3 tools/verify-shared-artifacts.py - - name: Preserve test reports and shared artifacts - if: always() - uses: actions/upload-artifact@v4 - with: - name: shared-library-validation - retention-days: 7 - if-no-files-found: warn - path: | - simpleapi-*/target/*.jar - simpleapi-*/target/surefire-reports/*.xml - SimpleAPI/target/surefire-reports/*.xml From 8511a30bdb9080b18b9bbb141c7a18fc568dde8b Mon Sep 17 00:00:00 2001 From: Ben Date: Mon, 7 Sep 2026 18:09:54 -0600 Subject: [PATCH 6/6] Document manual shared-library checks after workflow removal --- docs/shared-libraries.md | 36 ++++++++++++++++++++++-------------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/docs/shared-libraries.md b/docs/shared-libraries.md index 0f512abd..8751adb6 100644 --- a/docs/shared-libraries.md +++ b/docs/shared-libraries.md @@ -149,17 +149,25 @@ Keep all four artifacts' versions aligned when releasing. ## Verification -`shared-libraries.yml` builds the shared and full artifacts on JDK 21, runs the -existing headless/duration tests and new configuration tests, checks packaged JAR -class boundaries, and compiles a consumer against packaged JARs (not target/classes). -A separate probe compares actual Bukkit scalar/list and SQL configuration behavior. -Source staging is checked byte-for-byte against the one maintained implementation. - -The workflow validates only this repository. It does not check out AdvancedCore -or VotingPlugin, pin their commits, or assume particular plugin artifact versions. -Maven installs the current reactor artifacts into the runner's local cache solely -for these checks; nothing is published remotely. The temporary feature-branch -push trigger is removed, avoiding duplicate push/PR runs for that branch. +No additional shared-library GitHub Actions workflow is included. The existing +`maven.yml` remains unchanged and runs `mvn -B -f SimpleAPI/pom.xml package`; +it does not build or test the sibling shared modules. Run their tests explicitly +using the root or shared-module build commands above. + +The packaged-artifact probes remain available for manual validation. From the +repository root with JDK 21, Maven and Python 3 available, run: + +```sh +mvn -B -ntp clean install +mvn -B -ntp -nsu -pl simpleapi-core,simpleapi-configurate,simpleapi-sql -am org.apache.maven.plugins:maven-dependency-plugin:3.8.1:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/runtime-deps +mvn -B -ntp -nsu -f SimpleAPI/pom.xml org.apache.maven.plugins:maven-dependency-plugin:3.8.1:build-classpath -Dmdep.outputFile=target/compatibility-classpath.txt +python3 tools/verify-shared-artifacts.py +``` + +These commands install artifacts only into the local Maven repository, not a +remote repository. The probes check packaged shared-class boundaries, compile +and run a native consumer against the JARs, compare Bukkit/shared configuration +behavior, and check source staging against the maintained implementation. Cross-repository builds remain an explicit check for significant API changes or release preparation, not an automatic dependency of every SimpleAPI PR. The @@ -177,6 +185,6 @@ their game operations belong behind adapters during that extraction. Do not repl entity-aware scheduling with a generic global-thread executor. The separate HTTP transport work from PR #73 is not copied or reworked here. It -merged into main while this change was being validated; the PR merge-ref build -tests compatibility with it. Packaging that transport as a thin native dependency -is a separate follow-up, not part of these configuration/data/SQL artifacts. +merged into main while this change was being validated; the initial PR integration +build included it. Packaging that transport as a thin native dependency is a +separate follow-up, not part of these configuration/data/SQL artifacts.