API: The Java API for plugins: logging edits, reading history, running rollbacks, purging history, and loading the worlds a plugin keeps # Developer API > What your plugin can do with the Oasis API, with one example from start to finish. Oasis has a Java API for other Paper plugins. With it, your plugin can: * **Log its own edits.** When your plugin changes blocks, moves items or spawns entities, log them. Staff can then look them up and roll them back like anything a player did. * **Read history.** Ask for the edits a player made, the edits in an area, the whole history of one block, or the positions an operation kept, a page at a time. * **Run rollbacks, restores and undos.** Start the same operations staff run with `/rollback`, `/rs` and `/undo`, follow their progress, and read what they did. * **Purge history.** Delete edits for good, the same way /oasis purge does, even in a world that is not loaded. * **Read and clear incidents.** List the problems the store recorded, and clear them, as `/oasis incidents` does. * **Load the worlds it keeps.** Give Oasis a loader for worlds in your plugin’s own storage, such as slime worlds, so a rollback can write into them while they are not loaded. * **React to operations and purges.** Listen for the end of every operation and every purge, and for each chunk an operation rewrites, whoever started it. The API lives in one package, `com.oasis.api`, and its events live in `com.oasis.api.event`. Everything else in the Oasis jar is internal and can change at any time. ## A first example [Section titled “A first example”](#a-first-example) This plugin asks for the API when it starts. Its `revert` method shows a staff member the newest edit a player made in the last hour, then rolls back that hour and reports the result. ```java package com.example.quickstart; import com.oasis.api.Edit; import com.oasis.api.Oasis; import com.oasis.api.OasisApi; import com.oasis.api.Selection; import java.time.Duration; import org.bukkit.OfflinePlayer; import org.bukkit.entity.Player; import org.bukkit.plugin.java.JavaPlugin; public final class QuickStart extends JavaPlugin { private OasisApi oasis; @Override public void onEnable() { // Oasis is in `depend` of plugin.yml, so it is enabled before this runs. oasis = Oasis.api(this); } public void revert(Player staff, OfflinePlayer griefer) { Selection lastHour = Selection.all().by(griefer).within(Duration.ofHours(1)); oasis.lookups().find(lastHour, 1).thenAccept(page -> { if (page.items().isEmpty()) { staff.sendMessage(griefer.getName() + " made no edits in the last hour."); return; } Edit newest = page.items().getFirst(); staff.sendMessage("Newest edit: " + newest.cause().word() + " " + newest.after() + " at " + newest.position().x() + " " + newest.position().y() + " " + newest.position().z()); oasis.operations().rollback(lastHour) .requestedBy(staff) .audience(staff) .start() .result().thenAccept(result -> staff.sendMessage("Rolled back " + result.applied() + " edits. Kept " + result.kept() + ".")); }); } } ``` Every future in the API completes on the main thread, so both callbacks can send messages and touch the world. Because the request names `staff` as its audience, the staff member also sees the start line, the progress bar and the summary, just as for a command. ## The pages [Section titled “The pages”](#the-pages) [Getting started](/docs/api/getting-started/)Add the dependency, declare Oasis in your plugin file, and get the API. [Core concepts](/docs/api/concepts/)The person and the actor, the thread rule, pages without totals, and nullness. [Selecting edits](/docs/api/selections/)Every Selection method: who, when, where and what. [Reading history](/docs/api/reading-history/)Pages of edits, the history of one block, and every field of an Edit. [Logging edits](/docs/api/logging-edits/)Log the blocks, items and entities your plugin changes. [Running operations](/docs/api/operations/)Rollbacks, restores and undos: estimate, start, follow, cancel and read the result. [Purging history](/docs/api/purges/)Delete edits for good: count, start, follow and read the result. Purge a world that is not loaded. [Incidents](/docs/api/incidents/)List the problems the store recorded, and clear them. [Loading your worlds](/docs/api/worlds/)Give Oasis a loader for the worlds your plugin keeps, so a rollback can write into them. [Events](/docs/api/events/)Redraw a map after a rollback, and report every operation and purge that ends. [Vocabulary](/docs/api/reference/)Every kind, cause, actor, phase, outcome and failure reason. ## For AI agents [Section titled “For AI agents”](#for-ai-agents) If you build with an AI coding agent, give it the API pages as plain text, or install the `oasis-api` skill. See [For AI agents](/docs/api/ai-agents/). # Getting started > Add the Oasis API to your build, declare Oasis in your plugin file, and get the API in onEnable. The API is one small jar, `com.oasis:oasis-api`. It holds interfaces and a few records and enums. The Oasis plugin on the server holds the code behind them, so your plugin compiles against the jar and Oasis provides it at runtime. The API needs Java 21 or newer and Paper 1.21.11 or newer. ## Set up your plugin [Section titled “Set up your plugin”](#set-up-your-plugin) 1. **Add the repository and the dependency.** The API is on the Oasis Maven repository, `https://telesphorepo.com/maven/oasis/`. The current version is `3.0-SNAPSHOT`. * Gradle (Kotlin) build.gradle.kts ```kotlin repositories { maven("https://repo.papermc.io/repository/maven-public/") maven("https://telesphorepo.com/maven/oasis/") } dependencies { compileOnly("io.papermc.paper:paper-api:1.21.11-R0.1-SNAPSHOT") compileOnly("com.oasis:oasis-api:3.0-SNAPSHOT") } ``` * Gradle (Groovy) build.gradle ```groovy repositories { maven { url = 'https://repo.papermc.io/repository/maven-public/' } maven { url = 'https://telesphorepo.com/maven/oasis/' } } dependencies { compileOnly 'io.papermc.paper:paper-api:1.21.11-R0.1-SNAPSHOT' compileOnly 'com.oasis:oasis-api:3.0-SNAPSHOT' } ``` * Maven pom.xml ```xml papermc https://repo.papermc.io/repository/maven-public/ oasis https://telesphorepo.com/maven/oasis/ io.papermc.paper paper-api 1.21.11-R0.1-SNAPSHOT provided com.oasis oasis-api 3.0-SNAPSHOT provided ``` 2. **Keep the API out of your jar.** Use `compileOnly` in Gradle and `provided` in Maven, as above. Never shade or relocate the API into your plugin. Oasis provides those classes on the server, and a second copy in your jar is a different class to Java. Oasis would not recognize it, and `Oasis.api(this)` would fail. 3. **Declare Oasis in your plugin file.** Oasis must enable before your plugin, so the API is ready in your `onEnable`. Use `depend` when your plugin can’t run without Oasis, and `softdepend` when Oasis is optional. * plugin.yml plugin.yml ```yaml name: GriefRevert version: 1.0.0 main: com.example.griefrevert.GriefRevert api-version: '1.21.11' # Oasis is required: depend: [Oasis] # Or, when Oasis is optional: # softdepend: [Oasis] ``` * paper-plugin.yml paper-plugin.yml ```yaml name: GriefRevert version: 1.0.0 main: com.example.griefrevert.GriefRevert api-version: '1.21.11' dependencies: server: Oasis: load: BEFORE # false when Oasis is optional required: true # Lets your plugin see the com.oasis.api classes. join-classpath: true ``` A Paper plugin needs `join-classpath: true`. Without it, your plugin can’t load the `com.oasis.api` classes even when Oasis runs. 4. **Get the API in `onEnable`.** Ask for the API once, with your own plugin, and keep it in a field. ```java package com.example.griefrevert; import com.oasis.api.Oasis; import com.oasis.api.OasisApi; import org.bukkit.plugin.java.JavaPlugin; public final class GriefRevert extends JavaPlugin { private OasisApi oasis; @Override public void onEnable() { oasis = Oasis.api(this); } } ``` `Oasis.api(this)` throws `IllegalStateException` when Oasis is not enabled. With `depend`, Paper doesn’t enable your plugin without Oasis, so that only happens when Oasis itself failed to start. The API you get acts for your plugin. See [What the API acts for](/docs/api/concepts/#what-the-api-acts-for). ## When Oasis is optional [Section titled “When Oasis is optional”](#when-oasis-is-optional) With `softdepend`, your plugin also runs on servers without Oasis. On those servers the `com.oasis.api` classes are not on the classpath at all. Java loads a class the first time your code needs it, so any code that names an Oasis type throws `NoClassDefFoundError` when it runs, or when Java loads the class that holds it. The safe pattern is to keep every Oasis reference in one class of your own, and to load that class only after you check that Oasis is enabled. Your main class names only that class, never an Oasis type. OasisHook.java ```java package com.example.softdepend; import com.oasis.api.Cause; import com.oasis.api.Oasis; import com.oasis.api.OasisApi; import org.bukkit.block.BlockState; import org.bukkit.entity.Player; import org.bukkit.plugin.Plugin; /** Every Oasis call of the plugin. Nothing else in the plugin imports com.oasis.api. */ final class OasisHook { private final OasisApi api; OasisHook(Plugin plugin) { api = Oasis.api(plugin); } void placed(Player player, BlockState before, BlockState after) { api.logger().by(player).block(Cause.PLACING, before, after); } } ``` SoftDepend.java ```java package com.example.softdepend; import org.bukkit.block.BlockState; import org.bukkit.entity.Player; import org.bukkit.plugin.java.JavaPlugin; public final class SoftDepend extends JavaPlugin { /** Null when Oasis is not on the server. */ private OasisHook oasis; @Override public void onEnable() { if (getServer().getPluginManager().isPluginEnabled("Oasis")) { oasis = new OasisHook(this); getLogger().info("Oasis found. Edits of this plugin go into its history."); } } /** Called by the rest of the plugin, with or without Oasis. */ public void logPlacement(Player player, BlockState before, BlockState after) { if (oasis != null) { oasis.placed(player, before, after); } } } ``` A field or a variable of type `OasisHook` is safe, because Java loads `OasisHook` only when the code first creates one or calls it. Keep these rules: * No `com.oasis.api` import in the main class, or in any class that loads when Oasis is missing. * No Oasis type in a field, a parameter, a return type, a lambda or a method reference of those classes. * Register an Oasis event listener from inside the hook class, after the check. A listener class names the event type, so it must not load on a server without Oasis. ## The Javadoc in your IDE [Section titled “The Javadoc in your IDE”](#the-javadoc-in-your-ide) Oasis publishes a sources jar and a Javadoc jar beside the API jar. Your IDE shows the Javadoc of every method as you type, and the Javadoc states the same rules as these pages: the thread of each callback, what throws, and what each value means. IntelliJ IDEA downloads the sources for a Gradle project on its own. In a Maven project, click **Download Sources** in the editor banner, or run `mvn dependency:sources`. # Core concepts > The person and the actor, immutable selections, the thread rule, pages without totals, nullness, and who the API acts for. A few ideas run through the whole API. Read this page once, and the rest of the API reads the way you expect. ## The person and the actor [Section titled “The person and the actor”](#the-person-and-the-actor) Every edit names two things: * The **person** is who is behind the edit: the player who acted, or the player who started a chain that the world carried on. * The **actor** is the world actor that carried the edit, such as `Actor.LAVA`. It is empty for an edit a person made directly. Take a player who pours lava. The lava flows down a hill and sets a forest on fire. Oasis logs each block the lava and the fire change with the player as the person, and with `Actor.LAVA` or `Actor.FIRE` as the actor. So a rollback of that player puts the whole forest back, and a lookup still tells you that lava did it. When nobody is behind an edit, such as lava that flows out of a natural lava lake, the person is the id of the [`Actor`](/docs/api/reference/#actor) that acted. A world actor, such as `Actor.LAVA`, is then the actor of the edit too: `edit.person()` is `Actor.LAVA.id()`, and `edit.actor()` holds `Actor.LAVA`. `Actor.of(edit.person())` turns a person id back into an actor, and is empty for a player. The logger and the reader use the same two words. You log the lava above as: ```java oasis.logger().by(player).via(Actor.LAVA).block(Cause.FLOW, at, air, lava); ``` And you read it back with `edit.person()`, which is the player’s id, and `edit.actor()`, which holds `Actor.LAVA`. A `Selection.all().by(player)` chooses the edits of that person, the lava and the fire included. A `Selection.all().by(Actor.LAVA)` chooses all lava: the lava with nobody behind it, and the lava from any player’s bucket. ## Selections and requests are immutable [Section titled “Selections and requests are immutable”](#selections-and-requests-are-immutable) A `Selection` is a filter you build from `Selection.all()`. Every method returns a new selection and changes one part of it. It never changes the selection you call it on. ```java Selection base = Selection.all().within(Duration.ofHours(2)); Selection ofSteve = base.by(steve); // base still selects every player Selection ofAlex = base.by(alex); // Alex alone: ofSteve did not change base ``` Each method **replaces** its part. `by(steve).by(alex)` selects Alex only. To select both, name them in one call: `by(steve, alex)`. A part you never set does not narrow the selection. `not()` leaves edits out with the same verbs, and replaces its part the same way. An `OperationRequest` works the same way. You can keep one in a field, share it between threads, and start it as often as you like. See [Selecting edits](/docs/api/selections/) and [Running operations](/docs/api/operations/). ## The thread rule [Section titled “The thread rule”](#the-thread-rule) Three rules cover every call in the API: 1. **Every call returns at once.** No method waits for the disk. A method that needs the store returns a `CompletableFuture`. 2. **Every future and every callback completes on the main thread.** That covers pages, estimates, progress callbacks and results. You may touch the world, send messages and open inventories in any of them, with no scheduler call of your own. 3. **The logger takes edits from any thread.** Some of its methods read a Paper object on the thread that calls. [Logging edits](/docs/api/logging-edits/#threads) says which. Never join() on the main thread Never call `join()` or `get()` on an Oasis future from the main thread. The future completes on the main thread, so the main thread would wait for itself and the server would freeze. Use `thenAccept`, `thenCompose` and `whenComplete` instead. A call fails in one of three ways: * **A call you get wrong throws `IllegalArgumentException`** at once, such as a selection that cannot match anything. See [Selecting edits](/docs/api/selections/#a-selection-that-matches-nothing). * **A read completes exceptionally with an `OasisException`.** A lookup, a `Page.next()`, a history, a kept list, an estimate or a purge count can fail, for example when the store is still opening, holds too many requests, or when a selection names a world that does not exist. The exception carries a `Failure`. Handle it with `exceptionally` or `whenComplete`. See [When a read fails](/docs/api/reading-history/#when-a-read-fails). * **An operation or a purge always completes its result,** with the failure inside it: a failed operation ends with `Outcome.FAILED` and a [`Failure`](/docs/api/operations/#when-an-operation-fails), and a failed purge ends the same way with a [`PurgeResult`](/docs/api/purges/#the-result). When Oasis disables, for example while the server stops, it runs no more ticks. A future that has not completed by then never completes, and the logger drops the edits it gets. ## Pages carry no totals [Section titled “Pages carry no totals”](#pages-carry-no-totals) A `Page` holds its `items()`, `hasNext()`, and `next()`. It has no total, no page count and no page number. That is on purpose. To count a selection, the store must read every edit in it, and on a busy server that can be hundreds of millions of rows. A page reads its own edits and one more, so the first page costs the same however much history the server holds. `next()` reads on from the last edit of the page, so the tenth page costs what the first page costs. Progress works the same way. An operation counts the chunks it writes, not the edits it will write. When you need a size before an operation starts, ask for it by name with [`estimate()`](/docs/api/operations/#estimate-and-confirm), which reads the selection once and changes nothing. ## Nullness [Section titled “Nullness”](#nullness) Both packages, `com.oasis.api` and `com.oasis.api.event`, are `@NullMarked` with [JSpecify](https://jspecify.dev). No method takes or returns null. Your IDE and a null checker read the annotation and warn you. A value that can be absent is an `Optional` or an `OptionalLong`: `Edit.actor()`, `Edit.item()`, `Edit.operation()`, `Operation.id()`, `OperationEndEvent.operation()`, `Result.failure()`, `Result.incomplete()`, `Result.forceKept()` and the getters of a `Selection`. No id or count uses 0 or -1 to mean “none”. The text fields of an edit, `before()`, `after()` and `text()`, are an empty string when the edit has none. ## What the API acts for [Section titled “What the API acts for”](#what-the-api-acts-for) `Oasis.api(this)` returns an API that acts for your plugin. Oasis gives your plugin a person of its own, with the key `#plugin:` followed by your plugin’s name, such as `#plugin:GriefRevert`. * An operation you start names your plugin as the one who asked for it, until you name someone else with `requestedBy`. Staff see `#plugin:GriefRevert` in a lookup, and `/undo` of a staff member never reaches your plugin’s operations. * `oasis.id()` is the id of that person. Oasis makes it from the key, so it is the same on every server. * `oasis.key(person)` is the key staff type after `user` for a person that is not a player. It is an actor key such as `#lava`, or `#plugin:Name` for a plugin that asked for the API since the server started. It is empty for a player, and for an id Oasis does not know. Ask Bukkit for a player’s name. * `oasis.operations().active()` lists the operations your plugin started that still run. It does not list another plugin’s operations, or the ones staff typed. ```java String who = oasis.key(event.requester()) .orElseGet(() -> String.valueOf(Bukkit.getOfflinePlayer(event.requester()).getName())); ``` This turns any requester into a name: a plugin, an actor such as `#console`, or a player. # Selecting edits > Every Selection method, by who, when, where and what, with the rules each one follows. A `Selection` chooses edits. Lookups read the edits it chooses, rollbacks and restores act on them, and purges delete them. It is the API’s version of the filters staff type after `/lookup` and `/rollback`. Start from `Selection.all()`, which chooses every edit, and narrow it: ```java Selection grief = Selection.all() .by(griefer) .within(Duration.ofHours(2)) .near(staff.getLocation(), 30) .kinds(Kind.BLOCK, Kind.CONTAINER) .not().causes(Cause.WORLDEDIT); ``` A selection is split into parts: who, when, where, the kinds, the causes, the materials, and what it leaves out. Each method sets one part and **replaces** what that part held before. A part you never set does not narrow the selection, so `Selection.all()` holds every edit of every kind, chat, commands and logins included. Every method returns a new selection and leaves the old one as it was. ## Who [Section titled “Who”](#who) | Method | Chooses | | ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- | | `by(OfflinePlayer... players)` | Edits by these players, whether the player acted or started a chain the world carried on. | | `by(UUID... ids)` | Edits by these people: player ids, or `Actor.id()` ids. | | `by(Actor... actors)` | Edits these actors made or carried, such as `Actor.LAVA`: the lava with nobody behind it, and the lava that flowed from any player’s bucket. | People and actors share one part. An edit matches when its [person, or the actor that carried it](/docs/api/concepts/#the-person-and-the-actor), is one of the ids you name. So: * `by(player)` chooses what the player did, and what the world carried on for them: the lava they poured and the fire that lava started. * `by(Actor.LAVA)` chooses all lava, whoever is behind it. It is what staff get with `user #lava` in a command. The three `by` methods replace one another, so `by(player).by(Actor.LAVA)` selects all lava, not the player’s lava. To mix players and actors in one selection, pass their ids to `by(UUID...)`. An actor’s id there works as it does in `by(Actor...)`: ```java Selection griefOrCreepers = Selection.all().by(griefer.getUniqueId(), Actor.CREEPER.id()); ``` To find only the lava one player poured, select the player and check `edit.actor()` on each edit. A lookup of `by(player)` also reads the player’s logins and logouts. Add `kinds(Kind.BLOCK)` to read blocks alone. ## When [Section titled “When”](#when) | Method | Chooses | | --------------------------- | ---------------------------------------------------------------------------- | | `within(Duration duration)` | Edits in the last `duration`, counted from the moment the selection is read. | | `since(Instant time)` | Edits at or after this time. | | `until(Instant time)` | Edits at or before this time. | Times are inclusive at both ends. The three methods share the time part, and each one replaces what it finds there: * `within` replaces the whole time part. It removes a `since` and an `until`. * `since` replaces a `within` window, and keeps an `until`. * `until` replaces a `within` window, and keeps a `since`. A window always runs up to the moment of the read, so a selection never holds a window and an `until`. For a span that ends in the past, use `since` and `until` with an `Instant`: ```java // Edits from the last day, up to one hour ago. Instant now = Instant.now(); Selection dayBefore = Selection.all() .since(now.minus(Duration.ofDays(1))) .until(now.minus(Duration.ofHours(1))); // Edits older than thirty days. Selection old = Selection.all().until(Instant.now().minus(Duration.ofDays(30))); ``` `since` after `until` throws `IllegalArgumentException`. ### A window counts back from when it runs [Section titled “A window counts back from when it runs”](#a-window-counts-back-from-when-it-runs) `within` keeps the duration, not a fixed time. A lookup, an estimate or a start counts it back from the moment it reads the selection. So a request you build at noon and start at one o’clock, with `within(Duration.ofHours(2))`, rolls back the edits from eleven to one. ```java // Built once, in onEnable. OperationRequest lastTwoHours = oasis.operations() .rollback(Selection.all().by(griefer).within(Duration.ofHours(2))); // Started later. Each start reads the two hours before it. lastTwoHours.start(); ``` A start fixes the window once, when it begins. Its sweeps and its [`forceKept()`](/docs/api/operations/#kept-left-and-force) request then read the same edits as its first pass. `since` and `until` take an `Instant`, so they never move. ## Where [Section titled “Where”](#where) | Method | Chooses | | ----------------------------------------- | ---------------------------------------------------------------------------------------------------- | | `in(World... worlds)` | Edits anywhere in these worlds. | | `in(String... worlds)` | Edits anywhere in these worlds by their stored names, for a world that is not loaded. | | `near(Location center, int radius)` | Edits in the cube of this radius, in blocks, around the location. | | `box(Location corner, Location opposite)` | Edits in the box between two corners of one world. | | `at(Block block)` | Edits at one block, of every kind. | | `in(String world, Selection.Box box)` | Edits in a box of a world by its stored name, for a world that is not loaded. The box is never null. | All six share one part, the world and the box, so the last one you call wins. Boxes are inclusive. `near` is a cube, not a sphere, so `near(location, 0)` is the one block at that location. `box` takes its corners in any order. `Selection.Box` holds `minX`, `minY`, `minZ`, `maxX`, `maxY` and `maxZ`, all inclusive. It throws `IllegalArgumentException` for a lowest bound above its highest: ```java // The spawn area of a world that is not loaded right now. Selection spawn = Selection.all().in("world_old", new Selection.Box(-64, -64, -64, 64, 320, 64)); ``` ### A world that is not loaded [Section titled “A world that is not loaded”](#a-world-that-is-not-loaded) `in(String... worlds)` and `in(String world, Box box)` name a world that no server world has now. A world is not loaded when a staff member unloaded it, or when its folder is gone. The stored name is the one the world had when Oasis logged its edits. A lookup and a purge read and write the store alone. They take any world that is loaded, that Oasis saw loaded, or that holds edits in the store, with no folder needed. [A purge of whole worlds](/docs/api/purges/#a-purge-of-whole-worlds) deletes the history of such a world. A rollback, a restore and an undo write the world, so they need it. Oasis loads a world that is not loaded for the pass: through the [world loader](/docs/api/worlds/) of the plugin that keeps it, or from its folder. When nobody can load it, the operation fails with `WORLD_GONE` before it writes anything. Names are exact, as `World.getName()` reads them. Oasis checks every world name of a selection, chosen or left out, when a request reads it: a lookup, a history, an estimate, an operation, or a purge with its count. A name that is not loaded under that exact name, that Oasis never saw, and that the store holds no edits of fails the request with `Failure.Reason.NO_SUCH_WORLD`, so a typo never widens a rollback. ## What [Section titled “What”](#what) | Method | Chooses | | -------------------------------- | ---------------------------------------------------------------- | | `kinds(Kind... values)` | Edits of these kinds only, such as `Kind.BLOCK`. | | `causes(Cause... values)` | Edits with these causes only, such as `Cause.BREAKING`. | | `materials(Material... values)` | Edits of these blocks or items, before or after the edit. | | `entities(EntityType... values)` | Edits of these entities. | | `matching(String... patterns)` | Edits of blocks, items or entities by id or by a pattern of ids. | `kinds` and `causes` each have a part of their own. `materials`, `entities` and `matching` share one part, so the last of them you call wins. To choose blocks and entities in one selection, use `matching` with both ids: ```java Selection signsAndFrames = Selection.all().matching("*_sign", "item_frame"); ``` [Vocabulary](/docs/api/reference/) lists every `Kind` and `Cause`. ### Patterns [Section titled “Patterns”](#patterns) `matching` and `not().matching` take ids and patterns of ids: * An id is a namespaced id, such as `minecraft:oak_log` or `minecraft:zombie`. * An id with no namespace is a vanilla one: `oak_log` means `minecraft:oak_log`. * A `*` matches any run of characters: `*_sign` matches every sign, and `*_ore` every ore. * Oasis reads a pattern in lower case, so `OAK_LOG` works too. A value may hold only letters, digits, `_`, `-`, `.`, `/` and `*`, with at most one `:` between the namespace and the path. Anything else, such as a space or a `#`, throws `IllegalArgumentException`. ## Leaving edits out [Section titled “Leaving edits out”](#leaving-edits-out) `not()` returns the parts a selection leaves out. Each method takes the same values as the method of the same name above, leaves them out, and returns the selection. | Method | Leaves out | | ----------------------------------------------------------------------- | ----------------------------------------------------------------------------- | | `not().by(OfflinePlayer...)`, `not().by(UUID...)`, `not().by(Actor...)` | The edits of these people or actors, and every chain they started or carried. | | `not().in(World...)`, `not().in(String...)` | Edits in these worlds. | | `not().kinds(Kind...)` | Edits of these kinds. | | `not().causes(Cause...)` | Edits with these causes. | | `not().materials(Material...)` | Edits of these blocks or items. | | `not().entities(EntityType...)` | Edits of these entities. | | `not().matching(String...)` | Edits whose id matches one of these patterns. | `not().by(x)` is the exact inverse of `by(x)`: it leaves out what `x` did and the fire and water `x` set going. Each call replaces its part, as the rest of the selection does. `not().by(steve).not().by(alex)` leaves out Alex only. `not().materials`, `not().entities` and `not().matching` share one part. ```java // Everything a player broke or placed, but not their signs. Selection handWork = Selection.all() .by(builder) .causes(Cause.BREAKING, Cause.PLACING) .not().matching("*_sign"); // Everything near a location, except what one builder did. Selection others = Selection.all().near(center, 20).not().by(builder); ``` ## An empty list throws [Section titled “An empty list throws”](#an-empty-list-throws) Every method that takes a list of values throws `IllegalArgumentException` when the list is empty. That covers every `by`, `in`, `kinds`, `causes`, `materials`, `entities` and `matching`, and every one of them after `not()`. This protects you from a costly mistake. A part you never set chooses everything, so if an empty list meant “no filter”, it would choose every edit on the server. Picture a plugin that collects the griefers in a region, finds none, and rolls back `by()` of that empty list. It would roll back the whole server. So check your list before you pass it: ```java public void rollBackGriefers(List griefers, Location center) { if (griefers.isEmpty()) { return; } oasis.operations() .rollback(Selection.all().by(griefers.toArray(UUID[]::new)).near(center, 50)) .start(); } ``` To choose every player, leave the part unset. ## A selection that matches nothing [Section titled “A selection that matches nothing”](#a-selection-that-matches-nothing) A selection that cannot match any edit by how it is built throws `IllegalArgumentException` at the call that makes it, in either order of the calls: * A person, world, kind, cause or material is both chosen and left out. * `near`, `box`, `at` or `in(name, box)` names a world that is left out. * `since` is after `until`. ## Every rule that throws [Section titled “Every rule that throws”](#every-rule-that-throws) Each of these throws `IllegalArgumentException` when you build the selection, before anything reads the store: | Method | Throws when | | ----------------------------------------- | ---------------------------------------------------------------------------------------------------- | | `by(...)`, all three, and `not().by(...)` | No player, id or actor is named, or a person is both chosen and left out. | | `in(...)`, and `not().in(...)` | No world is named, or a world is both chosen and left out. | | `kinds(...)`, `not().kinds(...)` | No kind is named, or a kind is both chosen and left out. | | `causes(...)`, `not().causes(...)` | No cause is named, or a cause is both chosen and left out. | | `materials(...)`, `not().materials(...)` | No material is named, or a material is both chosen and left out. | | `entities(...)`, `not().entities(...)` | No entity type is named, or a type is both chosen and left out. | | `matching(...)`, `not().matching(...)` | No pattern is named, a value is not an id or a pattern of one, or an id is both chosen and left out. | | `near(center, radius)` | The radius is negative. | | `box(corner, opposite)` | The corners are in two worlds. | | `near`, `box`, `at`, `in(name, box)` | The world is left out. | | `since(time)`, `until(time)` | `since` is after `until`. | | `new Selection.Box(...)` | A lowest bound is above its highest. | The message of each exception says what was wrong and how to fix it, so you can show it to the person who typed the value. ## Reading a selection back [Section titled “Reading a selection back”](#reading-a-selection-back) A selection tells you what it chooses. Each getter returns an empty set or an empty `Optional` for a part you never set. | Getter | Returns | | --------------------- | -------------------------------------------------------------------------------------- | | `people()` | The ids chosen, or empty for everyone. | | `since()` | The time set with `since`. Empty when unset, and empty while a `within` window is set. | | `within()` | The window set with `within`, or empty. | | `until()` | The time set with `until`, or empty. | | `worlds()` | The stored world names, or empty for every world. | | `box()` | The box inside the one world of `worlds()`, or empty for whole worlds. | | `kinds()`, `causes()` | The kinds or causes chosen, or empty for all. | | `materials()` | The namespaced ids and patterns chosen, or empty for all. | | `left()` | What the selection leaves out, as a `Selection.Left`. | `left()` returns `Selection.Left(people, worlds, kinds, causes, materials)`. Each is a set, empty when nothing of that part is left out. `people` holds player ids and `Actor.id()` ids. `materials()` and `left().materials()` hold namespaced ids. A `Material.STONE` reads back as `minecraft:stone`, and a pattern `*_sign` as `minecraft:*_sign`. # Reading history > Read pages of edits with find, read the history of one block, and learn every field of an Edit. `oasis.lookups()` reads history, newest first, a page at a time. It has three ways in: `find` reads the edits a [selection](/docs/api/selections/) chooses, `history` reads the history of one block, as the inspector shows it, and `kept` lists the positions an operation kept. ## Find edits [Section titled “Find edits”](#find-edits) ```java oasis.lookups().find(Selection.all().by(player).within(Duration.ofHours(1))) .thenAccept(page -> { for (Edit edit : page.items()) { viewer.sendMessage(edit.cause().word() + " " + edit.after()); } }); ``` `find(selection)` returns the first page, with `Lookups.PAGE_SIZE` edits to a page. That is 8, the size staff read in chat. `find(selection, size)` takes a page size of your own, such as 45 for a chest inventory. A size below 1 throws `IllegalArgumentException`. The future completes on the main thread, so you can send messages and open inventories in the callback. ## Turn pages [Section titled “Turn pages”](#turn-pages) A `Page` has three methods. `find` and `history` return a `Page`, and `kept` returns a `Page`. | Method | Returns | | ----------- | ---------------------------------------------------------------------- | | `items()` | The items on this page, newest first for edits. The list may be empty. | | `hasNext()` | True when another page follows this one. | | `next()` | A future of the page after this one. It completes on the main thread. | `next()` throws `java.util.NoSuchElementException` when `hasNext()` is false, so check first: ```java private void showAll(Player viewer, Page page) { for (Edit edit : page.items()) { viewer.sendMessage(edit.cause().word() + " " + edit.after()); } if (page.hasNext()) { page.next().thenAccept(next -> showAll(viewer, next)); } } ``` Caution This loop reads every page, one after the other, which is fine for a small selection. For a selection that could hold millions of edits, read a page when the person asks for it, as in the [example below](#example-a-paged-history-command). `next()` reads on from the last edit of the page. Turning a page costs one page, however deep you are. A page keeps its place, so an edit that someone makes while you read does not push edits onto the next page. To see new edits, start again with `find`. A page has no total and no page number. See [Pages carry no totals](/docs/api/concepts/#pages-carry-no-totals) for the reason. When you want to show “more” without a count, `hasNext()` is all you need. ## The history of one block [Section titled “The history of one block”](#the-history-of-one-block) ```java oasis.lookups().history(block).thenAccept(page -> { for (Edit edit : page.items()) { String mark = edit.current() ? " (stands now)" : ""; viewer.sendMessage(edit.cause().word() + " " + edit.after() + mark); } }); ``` `history(block)` reads the history of one block the way the inspector shows it. It holds only the [kinds](/docs/api/reference/#kind) that describe the world at a place: blocks, containers, entities and items. It leaves out books, chat, commands, logins and logouts. `history(block, size)` takes a page size of your own, and a size below 1 throws. `history(BlockPosition)` reads the same history for a position whose world need not be loaded, such as the `position()` of an edit you read. `history(position, size)` takes a page size of your own. `history` marks the edit that stands at the block now with `current()`. At most one edit in all its pages has the mark. `Selection.all().at(block)` with `find` is different: it reads every kind at the block, chat included, and marks no edit as current. ## The positions an operation kept [Section titled “The positions an operation kept”](#the-positions-an-operation-kept) An operation leaves a position standing when a later edit by another person holds it. `kept(operation)` lists those positions, as `/oasis kept` does, a page at a time: ```java oasis.lookups().kept(operation.id().getAsLong()).thenAccept(page -> { for (Kept kept : page.items()) { BlockPosition at = kept.position(); viewer.sendMessage(at.x() + " " + at.y() + " " + at.z() + " is held by " + name(kept.keeper())); } }); ``` A `Kept` holds the `position()`, the `keeper()` (the person whose later edit holds the position: a player id or an `Actor.id()`), and `holding()`, the newest edit at the position. `holding()` is empty when a purge deleted the edits there. Any pass id of the operation’s group names it: the id of the operation, of a sweep, or of a force. After a force, the list holds what the force kept. An id that the history does not hold fails with `NO_SUCH_OPERATION`. `kept(operation, size)` takes a page size of your own, and a size below 1 throws. [Kept, left and force](/docs/api/operations/#kept-left-and-force) explains how an operation keeps a position. ## When a read fails [Section titled “When a read fails”](#when-a-read-fails) A read can fail when the store can’t answer. Then the future of `find`, `history`, `kept`, `Page.next()` or [`estimate()`](/docs/api/operations/#estimate-and-confirm) completes exceptionally with an `OasisException`. It still completes on the main thread. `OasisException` is a `RuntimeException` that carries a [`Failure`](/docs/api/reference/#failurereason), the same type a failed operation carries. Test `failure().reason()` in code, and show `failure().message()` to people. Its cause, when it has one, is the error Oasis met, for your server log. A read fails with one of these reasons: | Reason | What happened | What to do | | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | | `STARTING` | Oasis still opens its store, just after the server started. | Ask again shortly. | | `OVERLOADED` | The store holds as many requests as it takes. | Ask again shortly. | | `MOVING` | The history moves to another store with [`/oasis move`](/docs/packs-server/setup/#4-bring-your-history-along). | Ask again when the move ends. | | `STORE` | The store failed. | Tell staff. The server log names the error. | | `NOTHING_TO_UNDO` | Only for an estimate of `undoLast`: the person has run no operation. | Tell the person there is nothing to undo. | | `NO_SUCH_OPERATION` | For `kept`, and for an estimate of `undo`: the history does not hold that operation. | Check the id you passed. | | `NO_SUCH_WORLD` | The selection names a world, chosen or left out, that is not loaded under that exact name, that Oasis never saw, and that the store holds no edits of. Most often a typo. | Check the name. Names are exact, as `World.getName()` reads them. | A callback of `whenComplete` or `exceptionally` on the future that Oasis returns gets the `OasisException` itself. A callback on a stage further down the chain, such as after a `thenApply`, gets it wrapped in a `CompletionException`. This helper handles both: ```java /** The OasisException behind an error of an Oasis future, or null for any other error. */ private static OasisException oasisFailure(Throwable error) { Throwable cause = error instanceof CompletionException && error.getCause() != null ? error.getCause() : error; return cause instanceof OasisException failed ? failed : null; } ``` ```java oasis.lookups().find(selection).whenComplete((page, error) -> { if (error == null) { showAll(viewer, page); return; } OasisException failed = oasisFailure(error); if (failed == null) { getLogger().log(Level.WARNING, "The lookup failed", error); return; } switch (failed.failure().reason()) { case STARTING, OVERLOADED -> viewer.sendMessage(failed.failure().message() + " Try again in a moment."); case MOVING -> viewer.sendMessage(failed.failure().message() + " Try again when the move ends."); default -> { viewer.sendMessage("The lookup failed: " + failed.failure().message()); getLogger().log(Level.WARNING, "The lookup failed", failed); } } }); ``` A size below 1 and a `next()` with no next page throw at once, from the call itself. They never reach the future. ## The fields of an edit [Section titled “The fields of an edit”](#the-fields-of-an-edit) An `Edit` is a record. Each field is one of these: | Field | Type | What it holds | | ------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `time()` | `Instant` | When the edit happened. | | `person()` | `UUID` | Who is behind the edit: the player who acted or started the chain, or the id of the `Actor` that acted when no player is. | | `actor()` | `Optional` | The world actor that carried the edit, such as `Actor.LAVA`. Empty for an edit the person made directly. A world actor with nobody behind it is both the person and the actor. | | `position()` | `BlockPosition` | Where the edit happened: the stored world name, `x`, `y` and `z`. | | `kind()` | `Kind` | What changed: a block, a container, an entity, and so on. | | `cause()` | `Cause` | Why it changed, such as `Cause.BREAKING` or `Cause.FLOW`. | | `before()` | `String` | The block or entity before the edit, or an empty string. | | `after()` | `String` | The block or entity after the edit, or an empty string. | | `item()` | `Optional` | The items that moved, or empty for an edit that moved none. | | `text()` | `String` | The chat message, the command, or the book text, or an empty string. | | `reversed()` | `boolean` | True when a rollback, a restore or an undo has put the position back. | | `current()` | `boolean` | True on the edit that stands at the block now. Only `history` sets it. | | `operation()` | `OptionalLong` | The pass of a rollback, a restore or an undo that wrote this edit, or empty. | The person and the actor are explained in [Core concepts](/docs/api/concepts/#the-person-and-the-actor). To turn a person into a name, use `oasis.key(edit.person())` for an actor or a plugin, and Bukkit for a player: ```java private String name(UUID person) { return oasis.key(person).orElseGet(() -> { String player = Bukkit.getOfflinePlayer(person).getName(); return player == null ? person.toString() : player; }); } ``` ### before and after [Section titled “before and after”](#before-and-after) What `before()` and `after()` hold depends on the kind: | Kind | `before()` and `after()` | | ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `BLOCK` | The block data as a string, such as `minecraft:stone` or `minecraft:oak_stairs[facing=north,half=bottom,shape=straight,waterlogged=false]`. A broken block has `minecraft:air` after it. | | `ENTITY` | The namespaced entity id, such as `minecraft:zombie`. An entity that appeared has an empty `before()`. An entity that went away has an empty `after()`. | | `CONTAINER` and `ITEM` | Empty strings. `item()` says what moved. | | `CHAT`, `COMMAND` and `BOOK` | Empty strings. `text()` holds the message, the command or the book text. | `Bukkit.createBlockData(edit.after())` turns a block string back into `BlockData`, if you want to show or compare it. ### Items and their direction [Section titled “Items and their direction”](#items-and-their-direction) `item()` holds an `ItemChange` with two fields: `type()`, the namespaced item id such as `minecraft:diamond`, and `amount()`, how many moved. The amount is always positive. The cause says which way the items went: | Cause | Direction | | ---------- | ----------------------------------------------- | | `TAKING` | Out of a container. | | `PUTTING` | Into a container. | | `DROPPING` | Out of the player’s inventory, onto the ground. | | `PICKUP` | From the ground, into the player’s inventory. | ### reversed and current [Section titled “reversed and current”](#reversed-and-current) `reversed()` is true when a rollback, a restore or an undo has put the position back. A rolled-back grief reads as reversed, and the inspector shows it crossed out. After a rollback, the history of the block also holds the edit Oasis wrote to put the block back. That edit has `operation()` set. `current()` marks the one edit whose result stands at the block now. On a page of `find`, it is always false. ### operation [Section titled “operation”](#operation) `operation()` holds an id when Oasis wrote the edit itself, for a rollback, a restore or an undo. It is the id of the pass that wrote the edit: the first pass, a [sweep](/docs/api/operations/#sweeps), or a [force](/docs/api/operations/#kept-left-and-force). It is empty for an edit that Oasis did not write for an operation. Pass any of those ids to [`undo(id)`](/docs/api/operations/#undo), and Oasis reverses the whole operation: its first pass, its sweeps and its force. ## Example: a paged history command [Section titled “Example: a paged history command”](#example-a-paged-history-command) This plugin adds `/history `, which shows what a player did in the last seven days, a page at a time. `/history more` shows the next page. It keeps the last page each viewer read, and reads the next one only when the viewer asks. ```java package com.example.history; import com.oasis.api.Edit; import com.oasis.api.Failure; import com.oasis.api.Oasis; import com.oasis.api.OasisApi; import com.oasis.api.OasisException; import com.oasis.api.Page; import com.oasis.api.Selection; import io.papermc.paper.command.brigadier.BasicCommand; import io.papermc.paper.command.brigadier.CommandSourceStack; import java.time.Duration; import java.time.Instant; import java.util.HashMap; import java.util.Map; import java.util.UUID; import java.util.logging.Level; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.event.ClickEvent; import net.kyori.adventure.text.format.NamedTextColor; import net.kyori.adventure.text.format.TextDecoration; import org.bukkit.Bukkit; import org.bukkit.OfflinePlayer; import org.bukkit.entity.Player; import org.bukkit.plugin.java.JavaPlugin; public final class HistoryPlugin extends JavaPlugin { private static final int PAGE_SIZE = 10; private final Map> lastPage = new HashMap<>(); private OasisApi oasis; @Override public void onEnable() { oasis = Oasis.api(this); registerCommand("history", "Show what a player did, a page at a time", new HistoryCommand()); } private final class HistoryCommand implements BasicCommand { @Override public void execute(CommandSourceStack source, String[] args) { if (!(source.getSender() instanceof Player viewer)) { source.getSender().sendMessage("Only players can read history pages."); return; } if (args.length != 1) { viewer.sendMessage(Component.text("Usage: /history or /history more", NamedTextColor.RED)); return; } if (args[0].equalsIgnoreCase("more")) { Page page = lastPage.get(viewer.getUniqueId()); if (page == null || !page.hasNext()) { viewer.sendMessage(Component.text("There is no next page.", NamedTextColor.GRAY)); return; } page.next().whenComplete((next, error) -> show(viewer, next, error)); return; } OfflinePlayer target = Bukkit.getOfflinePlayerIfCached(args[0]); if (target == null) { viewer.sendMessage(Component.text("No player called " + args[0] + " has played here.", NamedTextColor.RED)); return; } Selection selection = Selection.all().by(target).within(Duration.ofDays(7)); oasis.lookups().find(selection, PAGE_SIZE).whenComplete((page, error) -> show(viewer, page, error)); } @Override public String permission() { return "history.use"; } } /** Runs on the main thread, because every Oasis future completes there. */ private void show(Player viewer, Page page, Throwable error) { if (!viewer.isOnline()) { return; } if (error != null) { // The future comes straight from Oasis, so the error is the OasisException itself. String why = error instanceof OasisException failed ? failed.failure().message() : "an error, see the log"; viewer.sendMessage(Component.text("The lookup failed: " + why, NamedTextColor.RED)); if (!(error instanceof OasisException failed) || failed.failure().reason() == Failure.Reason.STORE) { getLogger().log(Level.WARNING, "A history lookup failed", error); } return; } lastPage.put(viewer.getUniqueId(), page); if (page.items().isEmpty()) { viewer.sendMessage(Component.text("No edits found.", NamedTextColor.GRAY)); return; } for (Edit edit : page.items()) { viewer.sendMessage(line(edit)); } if (page.hasNext()) { viewer.sendMessage(Component.text("[Next page]", NamedTextColor.AQUA) .clickEvent(ClickEvent.runCommand("/history more"))); } } private Component line(Edit edit) { Duration age = Duration.between(edit.time(), Instant.now()); String who = name(edit.person()) + edit.actor().map(actor -> " via " + actor.key()).orElse(""); String where = edit.position().x() + " " + edit.position().y() + " " + edit.position().z(); Component text = Component.text(ago(age) + " " + who + " " + edit.cause().word() + " " + what(edit) + " at " + where); // Oasis crosses out what a rollback put back. Do the same. return edit.reversed() ? text.decorate(TextDecoration.STRIKETHROUGH) : text; } private String what(Edit edit) { return switch (edit.kind()) { case BLOCK, ENTITY -> shortId(edit.after().isEmpty() ? edit.before() : edit.after()); case CONTAINER, ITEM -> edit.item().map(item -> item.amount() + " " + shortId(item.type())).orElse(""); default -> edit.text(); }; } /** minecraft:oak_stairs[facing=north] reads as oak_stairs. */ private static String shortId(String id) { int bracket = id.indexOf('['); String plain = bracket < 0 ? id : id.substring(0, bracket); return plain.startsWith("minecraft:") ? plain.substring("minecraft:".length()) : plain; } private static String ago(Duration age) { if (age.toDays() > 0) { return age.toDays() + "d"; } if (age.toHours() > 0) { return age.toHours() + "h"; } return Math.max(1, age.toMinutes()) + "m"; } private String name(UUID person) { return oasis.key(person).orElseGet(() -> { String player = Bukkit.getOfflinePlayer(person).getName(); return player == null ? person.toString() : player; }); } } ``` A few things to note: * The command reads one page per request. Nothing reads ahead, and nothing counts. * `show` checks `isOnline()`, because the viewer can leave before the page arrives. * `lastPage` keeps one `Page` per viewer. A page holds its edits and its place, so it is cheap to keep. Remove a viewer’s entry in a `PlayerQuitEvent` listener if your server has many viewers. # Logging edits > Log the blocks, items and entities your plugin changes, so staff can look them up and roll them back. Oasis logs what players and the world do by listening to Bukkit events. When your plugin changes the world itself, with `setBlockData`, `setType`, `spawn` or an inventory call, no event fires and Oasis doesn’t see it. Log those edits through the API, and staff can look them up, inspect them and roll them back like any other. ```java EditLog log = oasis.logger().by(player); BlockState before = block.getState(); block.setType(Material.GOLD_BLOCK); log.block(Cause.PLACING, before, block.getState()); ``` ## Who made the edit [Section titled “Who made the edit”](#who-made-the-edit) Start with the person behind the edit. `oasis.logger()` returns an `EditLogger` with three ways in: | Method | Logs the edits of | | -------------------------- | ------------------------------------------------------------------------------------------------------------------------ | | `by(OfflinePlayer player)` | This player. | | `by(UUID person)` | This person: a player id, or an `Actor.id()` when no player is behind the edit. An actor’s id works as `by(Actor)` does. | | `by(Actor actor)` | The world, with no person behind the edit. | Each returns an `EditLog`. An `EditLog` is immutable, so keep it and reuse it for as many edits as you like. With `by(Actor.LAVA)`, the actor is both the person and the actor of the edit, as for the lava Oasis logs itself. You read it back with `edit.person()` as `Actor.LAVA.id()`, and `edit.actor()` holding `Actor.LAVA`. The same holds for every actor where `isWorld()` is true. `Actor.CONSOLE`, `Actor.WORLDEDIT` and `Actor.ROLLBACK` act as a person and carry nothing, so their edits read back with an empty `actor()`. ### Edits the world carried [Section titled “Edits the world carried”](#edits-the-world-carried) When the world carried the edit on for the person, such as lava that flowed from a bucket they poured, add the actor with `via`: ```java oasis.logger().by(player).via(Actor.LAVA).block(Cause.FLOW, at, air, lava); ``` `via(actor)` returns the same person with that actor beside them. A lookup names the person, with the actor beside them, and a rollback of the person takes the edit too. See [The person and the actor](/docs/api/concepts/#the-person-and-the-actor). `Edit.actor()` reads the actor back. `via` takes only an actor where `isWorld()` is true. It throws `IllegalArgumentException` for `Actor.WORLDEDIT`, `Actor.CONSOLE` and `Actor.ROLLBACK`, because those act as a person and carry nothing. To log an edit of the console, use `by(Actor.CONSOLE)`. ## Blocks [Section titled “Blocks”](#blocks) `EditLog` has four `block` methods. They differ in which side of the edit carries block entity data, such as a sign’s text or a chest’s items. | Method | Use it when | | -------------------------------------------------------------- | --------------------------------------------------------------------------------- | | `block(cause, BlockState before, BlockState after)` | Both sides may carry block entity data, or you don’t know. The safe default. | | `block(cause, BlockState before, BlockData after)` | A block with data became one without, such as a chest that was broken. | | `block(cause, BlockData before, BlockState after)` | A block with data took the place of one without, such as a chest that was placed. | | `block(cause, Location at, BlockData before, BlockData after)` | Neither side carries block entity data. Safe from any thread. | Take a `BlockState` with `block.getState()`: before the change for `before`, and after it for `after`. Oasis reads the block entity data of the state into the row, so a rollback brings a broken chest back full, and a restore puts the text back on a sign. ```java // A chest that your plugin breaks. The row keeps its items. BlockState chest = block.getState(); block.setType(Material.AIR); log.block(Cause.BREAKING, chest, Material.AIR.createBlockData()); // A chest that your plugin places and fills. BlockData air = block.getBlockData(); block.setType(Material.CHEST); log.block(Cause.PLACING, air, block.getState()); ``` `block(cause, BlockState, BlockState)` throws `IllegalArgumentException` when the two states are not of one block. ## Containers [Section titled “Containers”](#containers) | Method | Logs | | --------------------------------------------------- | ---------------------------------------------------------------------- | | `taken(Block container, ItemStack items)` | Items taken out of a container. A rollback puts them back. | | `taken(Block container, int slot, ItemStack items)` | Items taken out of one slot. A rollback puts them back into that slot. | | `put(Block container, ItemStack items)` | Items put into a container. A rollback takes them out again. | | `put(Block container, int slot, ItemStack items)` | Items put into one slot. A rollback takes them out of that slot. | The amount is the size of the `ItemStack`. Oasis reads the whole item, its name, enchantments and other data included, so a rollback puts back the same item. ```java // A shop plugin takes the payment out of the first slot of the shop chest. Inventory inventory = ((Container) block.getState(false)).getInventory(); ItemStack payment = inventory.getItem(0); if (payment != null) { inventory.setItem(0, null); oasis.logger().by(buyer).taken(block, 0, payment); } ``` Pass a slot when you know it. A rollback then puts the items back where they were. The cause of the row is `Cause.TAKING` or `Cause.PUTTING`. ## Items dropped and picked up [Section titled “Items dropped and picked up”](#items-dropped-and-picked-up) | Method | Logs | | ---------------------------------------- | --------------------------- | | `dropped(Location at, ItemStack items)` | Items the person dropped. | | `pickedUp(Location at, ItemStack items)` | Items the person picked up. | Staff can look these up, and a rollback does not change them. Their causes are `Cause.DROPPING` and `Cause.PICKUP`. ## Entities [Section titled “Entities”](#entities) | Method | Logs | | ------------------------------------- | ------------------------------------------------------------------------------------------- | | `spawned(Cause cause, Entity entity)` | An entity appeared. A rollback removes it. Call it once the entity is in the world. | | `removed(Cause cause, Entity entity)` | An entity went away. A rollback puts it back as it was. Call it before it leaves the world. | Oasis reads the whole entity, so a rollback brings back a removed entity with its data, such as an armor stand with its armor. ```java ArmorStand stand = world.spawn(at, ArmorStand.class); log.spawned(Cause.SPAWNING, stand); // Later: log it before it goes, while Oasis can still read it. log.removed(Cause.BREAKING, stand); stand.remove(); ``` ## Chat and commands [Section titled “Chat and commands”](#chat-and-commands) | Method | Logs | | -------------------------------------- | ---------------------------------- | | `chat(Location at, String message)` | A chat message. | | `command(Location at, String command)` | A command, with its leading slash. | Staff can look these up. They are not part of the history of a block, and a rollback does not change them. Log them when your plugin takes messages or commands that never reach Bukkit’s own events, such as messages from a Discord bridge or a custom chat channel. ## Threads [Section titled “Threads”](#threads) Every logger method returns at once and never waits for the disk. You may call the logger from any thread. Some methods read a Paper object on the thread that calls, so call those where the object may be read: | Method | Reads | Call it from | | ---------------------------------------------- | ----------------------------------- | ----------------------------------------------- | | `block(cause, Location, BlockData, BlockData)` | Nothing from the world. | Any thread. | | `block` with a `BlockState` | The block entity data of the state. | The main thread, for a block of a loaded chunk. | | `taken`, `put`, `dropped`, `pickedUp` | The `ItemStack`. | A thread that may read the item. | | `spawned`, `removed` | The whole entity. | The main thread, for a live entity. | | `chat`, `command` | Nothing from the world. | Any thread. | Oasis takes an edit you log on the main thread at once. It takes an edit you log on another thread on the next tick, in the order you logged it. ## Causes only Oasis writes [Section titled “Causes only Oasis writes”](#causes-only-oasis-writes) `Cause.ROLLBACK`, `Cause.UNDO` and `Cause.REDO` belong to Oasis. It writes them for its own operations and for a FAWE undo or redo. Every method that takes a cause throws `IllegalArgumentException` for them, before it reads anything. A row with one of those causes reads as an edit Oasis made, and a rollback treats it as one. If your plugin reverts its own work, log what it put back with an ordinary cause, such as `PLACING` for a block it put back. ## Choosing a cause [Section titled “Choosing a cause”](#choosing-a-cause) The API has no “plugin” cause. Pick the cause that says what happened in the world, as staff would type it after `cause`: * A block your plugin puts in the world: `PLACING`. * A block your plugin takes away: `BREAKING`. * A block that grows, melts or spreads through your plugin: `GROWTH`, `MELTING`, `SPREADING`. * An entity your plugin spawns: `SPAWNING`. A mob it kills: `KILLING`. An armor stand or a vehicle it breaks: `BREAKING`. [Vocabulary](/docs/api/reference/#cause) lists every cause. ## Edits that Oasis drops [Section titled “Edits that Oasis drops”](#edits-that-oasis-drops) Oasis drops an edit quietly, with no error and no message, when: * the server’s `config.yml` turns its cause off under [`logging.events`](/docs/reference/config/#logging-events), * its world is in [`logging.ignored-worlds`](/docs/reference/config/#logging-ignored-worlds), * or Oasis is not enabled. That is the same rule Oasis applies to the edits it logs itself, so server owners control your plugin’s edits with the settings they already know. There is no way to read whether an edit was dropped. Caution Don’t change the world yourself to undo an edit that Oasis logged. Start a [rollback](/docs/api/operations/) instead, so it keeps to the tick, logs what it puts back, and staff can undo it. ## Example: a builder’s wand [Section titled “Example: a builder’s wand”](#example-a-builders-wand) This plugin adds a builder’s wand. A player right-clicks a block face with a blaze rod, and the wand copies the clicked block onto every empty spot in front of the 3 by 3 area of that face, wherever the same block already is. Each block the wand places goes into the history under the player’s name, so a rollback of that player takes it away. ```java package com.example.wand; import com.oasis.api.Cause; import com.oasis.api.EditLog; import com.oasis.api.Oasis; import com.oasis.api.OasisApi; import net.kyori.adventure.text.Component; import org.bukkit.Material; import org.bukkit.block.Block; import org.bukkit.block.BlockFace; import org.bukkit.block.BlockState; import org.bukkit.block.data.BlockData; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.Listener; import org.bukkit.event.block.Action; import org.bukkit.event.player.PlayerInteractEvent; import org.bukkit.inventory.EquipmentSlot; import org.bukkit.inventory.ItemStack; import org.bukkit.plugin.java.JavaPlugin; public final class BuilderWand extends JavaPlugin implements Listener { private OasisApi oasis; @Override public void onEnable() { oasis = Oasis.api(this); getServer().getPluginManager().registerEvents(this, this); } @EventHandler public void onUse(PlayerInteractEvent event) { ItemStack held = event.getItem(); Block clicked = event.getClickedBlock(); if (event.getAction() != Action.RIGHT_CLICK_BLOCK || event.getHand() != EquipmentSlot.HAND || held == null || held.getType() != Material.BLAZE_ROD || clicked == null) { return; } event.setCancelled(true); Player player = event.getPlayer(); int placed = extend(player, clicked, event.getBlockFace()); player.sendActionBar(Component.text("Placed " + placed + " blocks")); } /** Copies the clicked block onto the face of the 3 by 3 area around it, and logs each block. */ private int extend(Player player, Block clicked, BlockFace face) { EditLog log = oasis.logger().by(player); BlockData copy = clicked.getBlockData(); int placed = 0; for (int a = -1; a <= 1; a++) { for (int b = -1; b <= 1; b++) { Block source = inPlane(clicked, face, a, b); Block target = source.getRelative(face); if (source.getType() != copy.getMaterial() || !target.isEmpty()) { continue; } // Take the state before and after the change, so the row holds both sides. BlockState before = target.getState(); target.setBlockData(copy); log.block(Cause.PLACING, before, target.getState()); placed++; } } return placed; } /** The block at (a, b) in the plane of the face, around the clicked block. */ private static Block inPlane(Block center, BlockFace face, int a, int b) { if (face.getModY() != 0) { return center.getRelative(a, 0, b); } if (face.getModX() != 0) { return center.getRelative(0, a, b); } return center.getRelative(a, b, 0); } } ``` The wand runs in an event handler, on the main thread, so it can use the `BlockState` form. A wand that copied plain blocks only could log with `block(Cause.PLACING, target.getLocation(), before, after)` and two `BlockData` values instead. Staff then find the wand’s work with `/lookup user Steve cause placing`, and `/rollback user Steve last 10m` takes it away. # Running operations > Run rollbacks, restores and undos. Build a request, estimate it, start it, follow its progress, cancel it, and read the result. An operation changes the world back. It is the same thing staff run with `/rollback`, `/rs` and `/undo`, with the same speed, the same care for later edits, and the same history afterwards. `oasis.operations()` has one method for each: | Method | Does | | ----------------------------------- | ---------------------------------------------------------------------------------- | | `rollback(Selection selection)` | Puts back what the chosen edits changed, as it stood before them. | | `restore(Selection selection)` | Puts the chosen edits back after a rollback took them away. | | `undo(long operation)` | Reverses this operation, and the sweeps and the force that followed it, as one. | | `undoLast(UUID requester)` | Reverses the last operation this person asked for, as `/oasis undo` does for them. | | `undoLast(CommandSender requester)` | The same for a sender: a player, or the console for any other sender. | Each returns an `OperationRequest`: an operation you have not started yet. The `Operation` it starts, and the events it fires, name the verb as an [`OperationType`](/docs/api/reference/#operationtype): `ROLLBACK`, `RESTORE` or `UNDO`. A sweep and a force have the type of the operation they follow. ```java oasis.operations().rollback(Selection.all().by(griefer).within(Duration.ofHours(2))) .requestedBy(staff) .audience(staff) .start() .result().thenAccept(result -> staff.sendMessage("Rolled back " + result.applied() + " edits.")); ``` ## The request [Section titled “The request”](#the-request) A request is immutable. Each method returns a new request, so you can keep one in a field and start it as often as you like. | Method | Sets | Default | | ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | | `requestedBy(UUID person)` | Who asked. Staff read this name in lookups, and `undoLast` and `/oasis undo` reverse the last operation of this person. | Your plugin, `#plugin:`. | | `requestedBy(CommandSender sender)` | Who asked: the player, or `Actor.CONSOLE` for any other sender. | | | `audience(Audience audience)` | Who sees it run: the start line, the progress bar and the summary, as a command shows them. | Nobody. | | `targetTick(Duration tick)` | The longest tick the operation may leave the server with. A longer tick finishes sooner, and the players feel it. A zero or negative duration throws `IllegalArgumentException`. | The config’s [`rollback.target-tick-ms`](/docs/reference/config/#rollback-target-tick-ms). | | `estimate()` | Nothing. It reads how large the operation is. See [Estimate and confirm](#estimate-and-confirm). | | | `start()` | Nothing. It starts the operation now and returns an `Operation`. | | Name the staff member who asked with `requestedBy`, so the history says who asked for the rollback, and that staff member can `/oasis undo` it. Any `Audience` works: a player, a `CommandSender`, the console, or `Audience.audience(staff, Bukkit.getConsoleSender())` for several at once. The audience also gets the force link of the summary, as a staff member does after a command. ## Estimate and confirm [Section titled “Estimate and confirm”](#estimate-and-confirm) Oasis never asks for a confirmation when you start an operation through the API. When your plugin wants to ask one, call `estimate()` first: ```java OperationRequest request = oasis.operations() .rollback(Selection.all().by(griefer).within(Duration.ofHours(2))) .requestedBy(staff); request.estimate().thenAccept(estimate -> staff.sendMessage("This rolls back " + estimate.edits() + " edits in " + estimate.chunks() + " chunks, in about " + estimate.duration().toSeconds() + " seconds.")); ``` An `Estimate` has three fields: | Field | Type | Holds | | ------------ | ---------- | -------------------------------------------------------------------------------- | | `edits()` | `long` | The edits the operation chooses. | | `chunks()` | `int` | The chunks those edits are in. | | `duration()` | `Duration` | How long it takes, at the rate this server has measured, within its target tick. | The estimate reads history and changes nothing. It is the one place in the API that reads a whole selection, so ask for it only when you show it to someone. The operation decides as it runs which of the chosen edits it writes and which a later edit holds, so `edits()` counts what it chooses, not what it writes. The [result](#the-result) splits those edits into `applied()`, `kept()` and `left()`, plus what is `incomplete()`. A sweep can add edits that the estimate could not see. The estimate of a [`forceKept()`](#kept-left-and-force) request is different. It names the kept positions and the chunks they lie in, as the operation that kept them counted them, and reads no history. When the store can’t answer, a world of the selection does not exist, or an undo names nothing to undo, the estimate future completes exceptionally with an `OasisException`. [When a read fails](/docs/api/reading-history/#when-a-read-fails) lists its reasons and shows how to handle it. See [Example: /griefrevert](#example-griefrevert) for a full confirmation flow. ## Start and follow an operation [Section titled “Start and follow an operation”](#start-and-follow-an-operation) `start()` returns an `Operation` at once. The pass begins on a later tick. | Method | Returns | | ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | | `id()` | The id of the operation, once history has recorded it: the id of its first pass. Empty before that. Pass it to `undo`. | | `type()` | What the operation does: `OperationType.ROLLBACK`, `RESTORE` or `UNDO`. | | `progress()` | Where the operation is now, as a `Progress`. | | `onProgress(Consumer listener)` | Adds a listener and returns the same `Operation`. See [onProgress](#onprogress). | | `result()` | A future of the `Result`. It completes on the main thread once the operation and its sweeps end. | | `cancel()` | Stops the operation. See [Cancel](#cancel). | You can read an `Operation` from any thread. ### Progress [Section titled “Progress”](#progress) An operation writes a chunk at a time, so the chunks measure how far it has come. | Field | Type | Holds | | -------------- | -------------- | --------------------------------------------------------------------------- | | `phase()` | `Phase` | `PLANNING`, `APPLYING`, `SWEEPING` or `FINISHED`. | | `edits()` | `long` | The edits written so far. | | `total()` | `OptionalLong` | The rows of the plan. Empty while the operation plans, or when it has none. | | `exact()` | `boolean` | False when `total()` is an upper bound. | | `chunks()` | `int` | The chunks the operation writes, or 0 while it plans. | | `doneChunks()` | `int` | The chunks it has finished. | | `startedAt()` | `Instant` | When the operation started. | `fraction()` is how far the operation has come, from 0 to 1: the finished chunks over all its chunks. It reads 0 while the operation plans. total() is not a count of edits `total()` counts the history rows of the selection in the chunks the operation claimed, and the rows an earlier operation reversed among them. An operation often writes fewer edits than that. Use `fraction()` for a bar, and `edits()` for a count of what it wrote. ### onProgress [Section titled “onProgress”](#onprogress) `onProgress` calls your listener on the main thread, at most once a tick, while the operation runs. When the operation ends, the listener always gets one last call with `Phase.FINISHED`, before `result()` completes. That holds after a pass, after a refusal such as `BUSY`, and after a cancel before the pass. A listener you add after the end gets no call. When a listener throws a `RuntimeException`, Oasis logs it as a warning in the server log and goes on. The other listeners still get their calls, and the result still completes. This listener draws the progress in a boss bar, and takes the bar away on the last call: ```java BossBar bar = BossBar.bossBar(Component.text("Rolling back"), 0f, BossBar.Color.GREEN, BossBar.Overlay.PROGRESS); staff.showBossBar(bar); Operation operation = request.start(); operation.onProgress(progress -> { bar.progress((float) progress.fraction()); if (progress.phase() == Phase.FINISHED) { staff.hideBossBar(bar); } }); ``` With an `audience`, Oasis draws its own progress bar for that audience. Leave the audience unset when you draw your own, or the staff member sees two bars. ### Sweeps [Section titled “Sweeps”](#sweeps) While a rollback runs, lava and water can keep flowing and fire can keep spreading. The first pass could not select those edits, because they did not exist yet. So after its first pass, a rollback runs **sweeps**: follow-up passes of the same operation that take what the world made while it ran. `Progress.phase()` reads `SWEEPING` during a sweep, and `Result.swept()` counts what the sweeps wrote. A restore, an undo and a force run one pass and sweep nothing. ## Cancel [Section titled “Cancel”](#cancel) `cancel()` stops the operation after the chunk it writes now. No sweep follows a cancel. The result then says `Outcome.CANCELLED`, and `applied()` counts what it wrote before it stopped. You can cancel from the moment `start()` returns, from any thread. A cancel from another thread acts on the main thread, at the next tick. A cancel before the pass begins, such as one in the same tick as `start()`, ends the operation before it writes anything. Its result is `CANCELLED` with nothing applied and no failure, and no `OperationEndEvent` fires for it. That holds even for a request that could not have run, such as an undo with nothing to undo. ```java Operation operation = request.start(); // Later, from a "Stop" button: operation.cancel(); ``` `oasis.operations().active()` lists the operations your plugin started that still run, so you can find one to cancel. It lists the operations of your plugin only, whoever `requestedBy` names. It does not list another plugin’s operations or the ones staff typed. ## The result [Section titled “The result”](#the-result) `result()` completes on the main thread with a `Result`. It never completes exceptionally: an operation that could not run ends with `Outcome.FAILED`. | Field | Type | Holds | | -------------- | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `outcome()` | `Outcome` | `FINISHED`, `CANCELLED` or `FAILED`. | | `failure()` | `Optional` | Why it failed, or empty when it did not fail. See [When an operation fails](#when-an-operation-fails). | | `applied()` | `long` | The edits it wrote, its sweeps included. | | `swept()` | `long` | The part of `applied()` the sweeps wrote. | | `kept()` | `long` | The positions it left standing because a later edit by another player held them. | | `left()` | `long` | The positions it left standing because the world holds another block than the history names, and no edit says why. | | `incomplete()` | `Optional` | What it chose and could not write, in words for people, or empty. | | `duration()` | `Duration` | How long it took: from the moment it took the operation slot to the end of its last sweep. For an operation that failed or was cancelled before that, from its start. | | `forceKept()` | `Optional` | A request that changes the kept positions too. Present only when the operation finished and kept positions. See below. | `kept()` and `left()` count each position once. An edit the operation could not write whatever the world holds, such as items for a container that is gone, or a block whose data does not read, is in `incomplete()` and in neither count. ## Kept, left and force [Section titled “Kept, left and force”](#kept-left-and-force) A rollback is careful. Say a griefer broke a wall, and another player rebuilt part of it since. A rollback of the griefer leaves the rebuilt part alone, so it doesn’t undo somebody’s repair. Those positions are **kept**, and `kept()` counts them. World changes, such as flowing water, never hold a position. A position is **left** when the world holds another block than the history names, and no edit says why. That can happen after a change that Oasis did not log, such as a plugin that changed blocks without the [logger](/docs/api/logging-edits/). No request changes a left position. When the person who asked wants the kept positions changed too, start `forceKept()`. It is a request that changes the kept positions and nothing else, over the later edits that held them. It is the same as the force link staff click after a command. `oasis.lookups().kept(id)` lists the kept positions themselves, each with the person who keeps it and the edit that holds it. See [The positions an operation kept](/docs/api/reading-history/#the-positions-an-operation-kept). ```java operation.result().thenAccept(result -> { result.forceKept().ifPresent(force -> { pendingForce.put(staff.getUniqueId(), force); staff.sendMessage(Component.text("Kept " + result.kept() + " blocks that other players changed since. ") .append(Component.text("[Force]", NamedTextColor.GOLD) .clickEvent(ClickEvent.runCommand("/griefrevert force")))); }); }); ``` `forceKept()` is present only when the operation finished and kept positions. It is empty: * when the operation kept nothing, * when it did not finish, that is, when its outcome is `CANCELLED` or `FAILED`, * for an undo, * for the result of a force, * and in an `OperationEndEvent`. A request has no force of its own, so a force always follows the operation that kept the positions. A cancel and a failure both close a pass as cancelled, and a force needs a finished pass to take its positions from. So a cancelled or failed operation never offers a force, even when `kept()` is above 0. Check `forceKept()`, not `kept()`, before you offer a force button. An `estimate()` of the force request names the kept positions and their chunks, so you can show its size before the staff member clicks. ## One operation at a time [Section titled “One operation at a time”](#one-operation-at-a-time) Oasis runs one operation at a time, for commands and plugins alike. A request that starts while another operation runs ends at once with `Outcome.FAILED` and `Failure.Reason.BUSY`. Nothing waits in a queue. To run yours as soon as the other ends, listen for [`OperationEndEvent`](/docs/api/events/#operationendevent). When it fires, the slot is free, and you can start your operation from inside the listener. ## When an operation fails [Section titled “When an operation fails”](#when-an-operation-fails) `Result.failure()` holds a `Failure` with two fields. Test `reason()` in code, and show `message()` to people. The message is the line a staff member reads for the same failure of a command. | `Failure.Reason` | What happened | What to do | | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | | `BUSY` | Another operation runs. Oasis runs one at a time. | Start yours again when it ends. | | `MOVING` | The history moves to another store with `/oasis move`. | Start it again when the move ends. | | `STARTING` | Oasis still opens its store, just after the server started. | Start it again shortly. | | `OVERLOADED` | The store holds as many requests as it takes. | Start it again shortly. | | `NOTHING_TO_UNDO` | `undoLast` names a person who has run no operation. | Tell the person there is nothing to undo. | | `NO_SUCH_OPERATION` | `undo` names an operation that the history does not hold. | Check the id you passed. | | `STORE` | The store failed: Oasis stopped logging, or the store could not read or record the operation. | Tell staff. The operation does not run again by itself, and the server log names the error. | | `PASS` | The pass stopped on an error after it began to write: the store failed under it, or a write to the world threw. | Tell staff. The edits it wrote stay, and `applied()` counts them. | | `NO_SUCH_WORLD` | The selection names a world, chosen or left out, that is not loaded under that exact name, that Oasis never saw, and that the history holds no edit of. Most often a typo. Nothing ran. | Show the message, which names the world. Check the name: names are exact, as `World.getName()` reads them. | | `WORLD_GONE` | The operation has edits in a world that is not loaded, that no [world loader](/docs/api/worlds/) owns, and that has no folder Oasis can load. It wrote nothing. | Show the message, which names each such world. Name the worlds you want with `in`, or leave the world out with `not().in(name)`. | | `WORLD_LOAD` | A world of the operation did not load: its world loader failed, or its folder did not open. | Tell staff; the message names the world and why. The edits written before stay. | | `WORLD_FORMAT` | A newer build of Oasis or Packs Server wrote values into a world of the operation, in a format this build does not know. The pass wrote nothing into that world. | Install that build or a newer one; the message names it. | A failure before the operation reaches the slot, such as `BUSY`, `NOTHING_TO_UNDO`, `NO_SUCH_WORLD` or `WORLD_GONE`, fires no `OperationEndEvent`, because no operation ran. ```java private void explain(Player staff, Result result) { Failure failure = result.failure().orElseThrow(); switch (failure.reason()) { case BUSY, STARTING, OVERLOADED -> staff.sendMessage(failure.message() + " Run the command again in a moment."); case MOVING -> staff.sendMessage(failure.message() + " Run the command again when the move ends."); default -> staff.sendMessage("The rollback failed: " + failure.message()); } } ``` ## Undo [Section titled “Undo”](#undo) `undo(id)` reverses one operation as a whole: its first pass, its sweeps and its force. Any pass id of the operation names all of it. That is the id of `Operation.id()`, the id of a sweep that `OperationChunkEvent.operation()` or `Edit.operation()` reads, or the id of a force. Keep the id from `Operation.id()`: ```java Operation operation = request.start(); operation.result().thenAccept(result -> operation.id().ifPresent(id -> lastOperation.put(staff.getUniqueId(), id))); // Later: Long id = lastOperation.get(staff.getUniqueId()); if (id != null) { oasis.operations().undo(id).requestedBy(staff).start(); } ``` `undoLast(requester)` reverses the last operation that person asked for, as `/oasis undo` does. Two cases are worth knowing: * `undoLast(staff.getUniqueId())` reverses the last operation the staff member asked for, whether they typed it or your plugin named them with `requestedBy`. * `undoLast(oasis.id())` reverses the last operation your plugin asked for in its own name. An undo runs one pass and sweeps nothing. Its result never has a `forceKept()`. ## Example: /griefrevert [Section titled “Example: /griefrevert”](#example-griefrevert) This plugin adds `/griefrevert [hours]`. It sizes the rollback and asks the staff member to confirm it, draws the progress in a boss bar, reports the result, and offers a force button when the rollback finished and kept positions. `/griefrevert cancel` stops a running rollback. ```java package com.example.griefrevert; import com.oasis.api.Failure; import com.oasis.api.Oasis; import com.oasis.api.OasisApi; import com.oasis.api.OasisException; import com.oasis.api.Operation; import com.oasis.api.OperationRequest; import com.oasis.api.Progress; import com.oasis.api.Result; import com.oasis.api.Selection; import io.papermc.paper.command.brigadier.BasicCommand; import io.papermc.paper.command.brigadier.CommandSourceStack; import java.time.Duration; import java.time.Instant; import java.util.HashMap; import java.util.Locale; import java.util.Map; import java.util.UUID; import java.util.logging.Level; import net.kyori.adventure.bossbar.BossBar; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.event.ClickEvent; import net.kyori.adventure.text.format.NamedTextColor; import org.bukkit.Bukkit; import org.bukkit.OfflinePlayer; import org.bukkit.entity.Player; import org.bukkit.plugin.java.JavaPlugin; public final class GriefRevert extends JavaPlugin { /** How long a staff member has to click Confirm or Force. */ private static final Duration CLICK_WINDOW = Duration.ofSeconds(60); /** A request that waits for a click. */ private record Pending(OperationRequest request, Instant asked) { boolean expired() { return asked.plus(CLICK_WINDOW).isBefore(Instant.now()); } } private final Map toConfirm = new HashMap<>(); private final Map toForce = new HashMap<>(); private final Map running = new HashMap<>(); private OasisApi oasis; @Override public void onEnable() { oasis = Oasis.api(this); registerCommand("griefrevert", "Roll back a griefer, after a confirmation", new Command()); } private final class Command implements BasicCommand { @Override public void execute(CommandSourceStack source, String[] args) { if (!(source.getSender() instanceof Player staff)) { source.getSender().sendMessage("Run /griefrevert in game."); return; } if (args.length == 0 || args.length > 2) { staff.sendMessage(Component.text("Usage: /griefrevert [hours]", NamedTextColor.RED)); return; } switch (args[0].toLowerCase(Locale.ROOT)) { case "confirm" -> start(staff, toConfirm.remove(staff.getUniqueId())); case "force" -> start(staff, toForce.remove(staff.getUniqueId())); case "cancel" -> cancel(staff); default -> ask(staff, args); } } @Override public String permission() { return "griefrevert.use"; } } /** Step 1: size the rollback, and ask the staff member to confirm it. */ private void ask(Player staff, String[] args) { OfflinePlayer griefer = Bukkit.getOfflinePlayerIfCached(args[0]); if (griefer == null) { staff.sendMessage(Component.text("No player called " + args[0] + " has played here.", NamedTextColor.RED)); return; } long hours; try { hours = args.length == 2 ? Long.parseLong(args[1]) : 2; } catch (NumberFormatException e) { hours = 0; } if (hours < 1) { staff.sendMessage(Component.text("Hours is a whole number of 1 or more.", NamedTextColor.RED)); return; } // The window counts back from the start, so a confirm a minute later still reads the last hours. OperationRequest request = oasis.operations() .rollback(Selection.all().by(griefer).within(Duration.ofHours(hours))) .requestedBy(staff); request.estimate().whenComplete((estimate, error) -> { if (error != null) { refused(staff, "Oasis could not size the rollback", error); return; } if (estimate.edits() == 0) { staff.sendMessage(Component.text(griefer.getName() + " made no edits to roll back.", NamedTextColor.GRAY)); return; } toConfirm.put(staff.getUniqueId(), new Pending(request, Instant.now())); staff.sendMessage(Component.text("Roll back " + estimate.edits() + " edits of " + griefer.getName() + " in " + estimate.chunks() + " chunks, in about " + estimate.duration().toSeconds() + "s? ") .append(button("[Confirm]", "/griefrevert confirm", NamedTextColor.GREEN))); }); } /** Step 2: start a request the staff member clicked, with a boss bar of its progress. */ private void start(Player staff, Pending pending) { if (pending == null || pending.expired()) { staff.sendMessage(Component.text("There is nothing to confirm. Run /griefrevert again.", NamedTextColor.RED)); return; } BossBar bar = BossBar.bossBar(Component.text("Rolling back"), 0f, BossBar.Color.GREEN, BossBar.Overlay.PROGRESS); staff.showBossBar(bar); Operation operation = pending.request().start(); running.put(staff.getUniqueId(), operation); operation.onProgress(progress -> { bar.progress((float) progress.fraction()); bar.name(Component.text(label(progress))); }); operation.result().thenAccept(result -> { staff.hideBossBar(bar); running.remove(staff.getUniqueId(), operation); report(staff, result); }); } private void cancel(Player staff) { Operation operation = running.get(staff.getUniqueId()); if (operation == null) { staff.sendMessage(Component.text("You have no rollback running.", NamedTextColor.GRAY)); return; } operation.cancel(); staff.sendMessage(Component.text("Stopping after the chunk it writes now.", NamedTextColor.YELLOW)); } /** Step 3: say what happened, and offer a force when the rollback kept positions. */ private void report(Player staff, Result result) { switch (result.outcome()) { case FINISHED -> staff.sendMessage(Component.text("Rolled back " + result.applied() + " edits in " + result.duration().toSeconds() + "s.", NamedTextColor.GREEN)); case CANCELLED -> staff.sendMessage(Component.text("Cancelled after " + result.applied() + " edits.", NamedTextColor.YELLOW)); case FAILED -> { staff.sendMessage(Component.text(advice(result.failure().orElseThrow()), NamedTextColor.RED)); return; } } if (result.left() > 0) { staff.sendMessage(Component.text("Left " + result.left() + " blocks that changed with no edit to say why.", NamedTextColor.GRAY)); } result.incomplete().ifPresent(what -> staff.sendMessage(Component.text("Could not write: " + what, NamedTextColor.GRAY))); // Present only for a rollback that finished and kept positions, never after a cancel. result.forceKept().ifPresent(force -> offerForce(staff, result.kept(), force)); } /** The estimate of a force names the kept positions and their chunks, from the counts of the rollback. */ private void offerForce(Player staff, long kept, OperationRequest force) { force.estimate().whenComplete((estimate, error) -> { if (error != null) { refused(staff, "Oasis could not size the force", error); return; } toForce.put(staff.getUniqueId(), new Pending(force, Instant.now())); staff.sendMessage(Component.text("Kept " + kept + " blocks in " + estimate.chunks() + " chunks that other players changed since. ", NamedTextColor.GOLD) .append(button("[Force]", "/griefrevert force", NamedTextColor.RED))); }); } /** An estimate fails with an OasisException that names the reason. */ private void refused(Player staff, String what, Throwable error) { if (error instanceof OasisException failed) { staff.sendMessage(Component.text(what + ": " + advice(failed.failure()), NamedTextColor.RED)); return; } staff.sendMessage(Component.text(what + ". The server log has the details.", NamedTextColor.RED)); getLogger().log(Level.WARNING, what, error); } private static String advice(Failure failure) { return switch (failure.reason()) { case BUSY, STARTING, OVERLOADED -> failure.message() + " Try again in a moment."; case MOVING -> failure.message() + " Try again when the move ends."; case STORE, PASS -> failure.message() + " The server log has the details."; default -> failure.message(); }; } private static String label(Progress progress) { return switch (progress.phase()) { case PLANNING -> "Planning the rollback"; case APPLYING -> "Rolling back: " + progress.doneChunks() + " of " + progress.chunks() + " chunks"; case SWEEPING -> "Sweeping up lava, water and fire"; case FINISHED -> "Done"; }; } private static Component button(String text, String command, NamedTextColor color) { return Component.text(text, color).clickEvent(ClickEvent.runCommand(command)); } } ``` The flow has three steps: 1. `/griefrevert Steve 3` builds a request and calls `estimate()`. Nothing changes yet. The staff member reads the size and clicks **Confirm**. 2. `/griefrevert confirm` starts the request it kept. The `within` window counts back from that start, not from the estimate. 3. When the result arrives, the plugin reports it. If the rollback finished and kept positions, it sizes `forceKept()` with `estimate()` and offers **Force**, which starts that request through the same `start` method. The estimate futures come straight from Oasis, so a failed one hands `whenComplete` the `OasisException` itself, with no `CompletionException` around it. The request names the staff member with `requestedBy(staff)`, so lookups show who asked, and the staff member can reverse the rollback with `/oasis undo`. # Purging history > Delete edits for good. Build a request, count it, start it, follow its progress, and read the result. Purge a world that is not loaded. A purge deletes edits from the history for good. It is the same thing staff run with `/oasis purge`, with the same checks and the same history afterwards. Nothing brings the edits back, and the store gives their disk space back. `oasis.purges()` has one method: | Method | Does | | ---------------------------- | ---------------------------------------- | | `purge(Selection selection)` | Deletes the edits the selection chooses. | It returns a `PurgeRequest`: a purge you have not started yet. The `Purge` it starts fires a [`PurgeEndEvent`](/docs/api/events/#purgeendevent) when it ends. ```java oasis.purges().purge(Selection.all().in("old_world")) .requestedBy(staff) .start() .result().thenAccept(result -> staff.sendMessage("Deleted " + result.deleted() + " edits.")); ``` A purge cannot be undone `/oasis undo` and `OperationRequest` reverse a rollback. Nothing reverses a purge. Call `count()` first when a person asks for it, and ask for a confirmation. ## The selection [Section titled “The selection”](#the-selection) A purge takes a [`Selection`](/docs/api/selections/), and the selection must narrow. `purge` throws `IllegalArgumentException` for `Selection.all()`, so a purge never deletes the whole history by mistake. Any part narrows: a person, a time, a world, a box, a kind, a cause or a material. `purge` also throws `IllegalArgumentException` when the selection names `Actor.ROLLBACK`, or leaves it out with `not().by(Actor.ROLLBACK)`. The rollback edits mark what a rollback reversed. If a purge deleted them, the reversed edits would stand again. A purge of anything else keeps the rollback edits, with one exception: a purge of whole worlds. A purge by age (a selection with only an `until` time) also hides the [incidents](/docs/api/incidents/) older than its cut. The store and the cairn keep them. A selection with no `until` time deletes the edits up to the moment of `start()`. A selection with an `until` time in the future is cut back to that moment. A purge never deletes an edit that is newer than its start. ### A purge of whole worlds [Section titled “A purge of whole worlds”](#a-purge-of-whole-worlds) A selection that names worlds and no other part, except `until`, is a purge of whole worlds. It deletes the whole history of those worlds, up to the cut: ```java // Every edit in two worlds that you retired. Selection retired = Selection.all().in("old_world", "old_nether"); // The same, but only the edits older than thirty days. Selection oldRetired = retired.until(Instant.now().minus(Duration.ofDays(30))); ``` A purge of whole worlds differs from a narrower purge in three ways: * It deletes the rollback and undo edits of those worlds too. A narrower purge keeps them. * It deletes the entries of those worlds from the operation log, for each operation that finished before the cut. A cut that falls inside an operation keeps the record and the undo of that operation. An operation that covered several worlds stays undoable in the other worlds. * It gives back the disk space of that log, as a purge by age does. Add any other part, such as `by(player)` or `kinds(Kind.BLOCK)`, and the purge is a narrower purge again. A narrower purge in a world keeps its rollback edits. `in(World...)` takes the worlds that are loaded. `in(String...)` takes worlds by their stored names. ### A world that is not loaded [Section titled “A world that is not loaded”](#a-world-that-is-not-loaded) A purge reads and writes the store alone, so it does not need the world. It takes a world that is not loaded, and a world whose folder is gone. Use `in(String...)` with the name the world had when Oasis logged its edits. Names are exact, as `World.getName()` reads them. Oasis checks every world name of the selection when you ask for `count()` and when you start. A name that is not loaded under that exact name, that Oasis never saw, and that the store holds no edits of fails with `Failure.Reason.NO_SUCH_WORLD`, so a typo never deletes anything. The command says `No world named X` for the same case. After a purge that names worlds, Oasis forgets each of those worlds that is not loaded and holds no edit any more. A later request that names such a world fails with `NO_SUCH_WORLD`. A world that is loaded stays known, because Oasis logs the edits in it. A rollback or a restore is different: it writes the world. Oasis loads an unloaded world for the pass, through the [world loader](/docs/api/worlds/) of the plugin that keeps it or from its folder. When nobody can load it, the operation does not run. See [Selecting edits](/docs/api/selections/#a-world-that-is-not-loaded). ## The request [Section titled “The request”](#the-request) A request is immutable. Each method returns a new request, so you can keep one in a field and start it as often as you like. | Method | Sets | Default | | ----------------------------------- | -------------------------------------------------------------------------------------------- | ------------------------------ | | `requestedBy(UUID person)` | Who asked. `PurgeEndEvent.requester()` reads this id. | Your plugin, `#plugin:`. | | `requestedBy(CommandSender sender)` | Who asked: the player, or `Actor.CONSOLE` for any other sender. | | | `count()` | Nothing. It counts the edits the purge deletes. See [Count and confirm](#count-and-confirm). | | | `start()` | Nothing. It starts the purge now and returns a `Purge`. | | A purge has no `audience`. Oasis draws no bar and sends no summary for a purge that a plugin starts. Draw your own with [`onProgress`](#onprogress). ## Count and confirm [Section titled “Count and confirm”](#count-and-confirm) Oasis never asks for a confirmation when you start a purge through the API. When your plugin wants to ask one, call `count()` first: ```java PurgeRequest request = oasis.purges() .purge(Selection.all().in("old_world")) .requestedBy(staff); request.count().thenAccept(edits -> staff.sendMessage("This deletes " + edits + " edits for good.")); ``` `count()` completes with a `Long`: the edits the purge deletes if it starts now. It counts each edit, so it reads as many rows as the purge deletes. A narrower purge keeps the rollback edits and the FAWE undo and redo edits, and the count leaves them out. Ask for it only when you show it to someone. When a world of the selection does not exist, or the store can’t answer, the future completes exceptionally with an `OasisException`. [When a read fails](/docs/api/reading-history/#when-a-read-fails) lists its reasons and shows how to handle it. ## Start and follow a purge [Section titled “Start and follow a purge”](#start-and-follow-a-purge) `start()` returns a `Purge` at once. The purge begins on the same tick when you call `start()` on the main thread, and on the next tick when you call it from another thread. | Method | Returns | | ---------------------------------------------- | ----------------------------------------------------------------------------------- | | `progress()` | Where the purge is now, as a `PurgeProgress`. | | `onProgress(Consumer listener)` | Adds a listener and returns the same `Purge`. See [onProgress](#onprogress). | | `result()` | A future of the `PurgeResult`. It completes on the main thread once the purge ends. | You can read a `Purge` from any thread. A purge has no `cancel()`. ### Progress [Section titled “Progress”](#progress) The store keeps the history in files. A purge rewrites or deletes each file that can hold an edit it deletes, so the files measure how far it has come. | Field | Type | Holds | | ------------- | --------- | --------------------------------------------------- | | `deleted()` | `long` | The edits deleted so far. | | `files()` | `int` | The files the purge rewrites, or 0 before it plans. | | `doneFiles()` | `int` | The files it has finished. | | `finished()` | `boolean` | True in the last call, when the purge ended. | | `startedAt()` | `Instant` | When the purge started. | `fraction()` is how far the purge has come, from 0 to 1: the finished files over all its files. It reads 0 before the purge plans. ### onProgress [Section titled “onProgress”](#onprogress) `onProgress` calls your listener on the main thread, every two seconds while the purge runs. When the purge ends, the listener always gets one last call with `finished()` true, before `result()` completes. A purge that does not start gets only the last call. A listener you add after the end gets no call. When a listener throws a `RuntimeException`, Oasis logs it as a warning in the server log and goes on. The other listeners still get their calls, and the result still completes. Add your listeners right after `start()`, in the same tick. Oasis gives a purge that cannot start its last call and its result one tick later, so a listener you add in the tick of `start()` still gets it. ## The result [Section titled “The result”](#the-result) `result()` completes on the main thread with a `PurgeResult`. It never completes exceptionally: a purge that could not start or that failed ends with `Outcome.FAILED`. | Field | Type | Holds | | ------------ | ------------------- | ---------------------------------------------------------------------------------------- | | `outcome()` | `Outcome` | `FINISHED` or `FAILED`. A purge has no cancel, so it never ends `CANCELLED`. | | `failure()` | `Optional` | Why it failed, or empty when it finished. See [When a purge fails](#when-a-purge-fails). | | `deleted()` | `long` | The edits the purge deleted. | | `duration()` | `Duration` | How long the purge ran, from `start()` to its end. | ## One purge at a time [Section titled “One purge at a time”](#one-purge-at-a-time) Oasis runs one purge at a time, for commands and plugins alike. A purge also cannot start while an operation runs that can hold its edits. A purge that names no player can hold the edits of any operation, so any running operation refuses it. A purge that names players is refused only while an operation runs that names one of them. A purge that is refused ends with `Outcome.FAILED` and `Failure.Reason.BUSY`. Nothing waits in a queue. To start yours as soon as the other ends, listen for [`PurgeEndEvent`](/docs/api/events/#purgeendevent) or [`OperationEndEvent`](/docs/api/events/#operationendevent). When the event fires, the slot is free, and you can start your purge from inside the listener. ## When a purge fails [Section titled “When a purge fails”](#when-a-purge-fails) `PurgeResult.failure()` holds a `Failure` with two fields. Test `reason()` in code, and show `message()` to people. The message is the line a staff member reads for the same failure of a command. | `Failure.Reason` | What happened | What to do | | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | | `BUSY` | A purge runs, or an operation runs that can hold the edits of this purge. | Start it again when that one ends. | | `REBUILDING` | The store rebuilds the runs that another build of Oasis wrote, after an update. | Start it again when the rebuild ends. | | `MOVING` | The history moves to another store with `/oasis move`. | Start it again when the move ends. | | `STARTING` | Oasis still opens its store, just after the server started. | Start it again shortly. | | `OVERLOADED` | The store holds as many requests as it takes. | Start it again shortly. | | `NO_SUCH_WORLD` | The selection names a world, chosen or left out, that is not loaded under that exact name, that Oasis never saw, and that the store holds no edits of. Most often a typo. | Check the name. | | `STORE` | The store failed, and the purge stopped. | Tell staff. The purge does not run again by itself, and the server log names the error. | A purge that fails with `STORE` may have deleted some edits before it stopped. `PurgeResult.deleted()` and `Purge.progress().deleted()` hold that count. ```java private void explain(Player staff, PurgeResult result) { Failure failure = result.failure().orElseThrow(); switch (failure.reason()) { case NO_SUCH_WORLD -> staff.sendMessage(failure.message() + " Check the name of the world."); case BUSY, REBUILDING, MOVING, STARTING, OVERLOADED -> staff.sendMessage(failure.message() + " Run the command again in a moment."); default -> staff.sendMessage("The purge failed: " + failure.message()); } } ``` A purge that Oasis refuses before it begins, such as one that ends with `BUSY` or `NO_SUCH_WORLD`, fires no `PurgeEndEvent`, because no purge ran. ## Example: /purgeworld [Section titled “Example: /purgeworld”](#example-purgeworld) This plugin adds `/purgeworld `. It counts the edits of the world, asks the staff member to confirm, draws the progress in a boss bar, and reports the result. The world can be unloaded, or even deleted. A name that no world and no edit knows fails the count with `NO_SUCH_WORLD`. ```java package com.example.purgeworld; import com.oasis.api.Failure; import com.oasis.api.Oasis; import com.oasis.api.OasisApi; import com.oasis.api.OasisException; import com.oasis.api.Outcome; import com.oasis.api.Purge; import com.oasis.api.PurgeRequest; import com.oasis.api.Selection; import io.papermc.paper.command.brigadier.BasicCommand; import io.papermc.paper.command.brigadier.CommandSourceStack; import java.util.HashMap; import java.util.Locale; import java.util.Map; import java.util.UUID; import java.util.logging.Level; import net.kyori.adventure.bossbar.BossBar; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.event.ClickEvent; import net.kyori.adventure.text.format.NamedTextColor; import org.bukkit.entity.Player; import org.bukkit.plugin.java.JavaPlugin; public final class PurgeWorld extends JavaPlugin { private final Map toConfirm = new HashMap<>(); private OasisApi oasis; @Override public void onEnable() { oasis = Oasis.api(this); registerCommand("purgeworld", "Delete the history of a world, after a confirmation", new Command()); } private final class Command implements BasicCommand { @Override public void execute(CommandSourceStack source, String[] args) { if (!(source.getSender() instanceof Player staff)) { source.getSender().sendMessage("Run /purgeworld in game."); return; } if (args.length != 1) { staff.sendMessage(Component.text("Usage: /purgeworld ", NamedTextColor.RED)); return; } if (args[0].toLowerCase(Locale.ROOT).equals("confirm")) { start(staff, toConfirm.remove(staff.getUniqueId())); } else { ask(staff, args[0]); } } @Override public String permission() { return "purgeworld.use"; } } /** Step 1: count the edits, and ask the staff member to confirm. */ private void ask(Player staff, String world) { // The name needs no loaded world. Oasis reads the store alone. PurgeRequest request = oasis.purges() .purge(Selection.all().in(world)) .requestedBy(staff.getUniqueId()); request.count().whenComplete((edits, error) -> { if (error != null) { refused(staff, "Oasis could not count the edits", error); return; } if (edits == 0) { staff.sendMessage(Component.text("The history holds no edit in " + world + ".", NamedTextColor.GRAY)); return; } toConfirm.put(staff.getUniqueId(), request); staff.sendMessage(Component.text("Delete " + edits + " edits in " + world + " for good? ") .append(Component.text("[Confirm]", NamedTextColor.RED) .clickEvent(ClickEvent.runCommand("/purgeworld confirm")))); }); } /** Step 2: start the request the staff member confirmed, with a boss bar of its progress. */ private void start(Player staff, PurgeRequest request) { if (request == null) { staff.sendMessage(Component.text("There is nothing to confirm. Run /purgeworld again.", NamedTextColor.RED)); return; } BossBar bar = BossBar.bossBar(Component.text("Purging"), 0f, BossBar.Color.RED, BossBar.Overlay.PROGRESS); staff.showBossBar(bar); Purge purge = request.start(); purge.onProgress(progress -> bar.progress((float) progress.fraction())); purge.result().thenAccept(result -> { staff.hideBossBar(bar); if (result.outcome() == Outcome.FAILED) { String why = result.failure().orElseThrow().message(); staff.sendMessage(Component.text("The purge failed after deleting " + result.deleted() + " edits: " + why, NamedTextColor.RED)); return; } staff.sendMessage(Component.text("Deleted " + result.deleted() + " edits in " + result.duration().toSeconds() + "s.", NamedTextColor.GREEN)); }); } /** A count fails with an OasisException that names the reason. */ private void refused(Player staff, String what, Throwable error) { if (error instanceof OasisException failed) { Failure failure = failed.failure(); staff.sendMessage(Component.text(what + ": " + failure.message(), NamedTextColor.RED)); return; } staff.sendMessage(Component.text(what + ". The server log has the details.", NamedTextColor.RED)); getLogger().log(Level.WARNING, what, error); } } ``` The flow has two steps: 1. `/purgeworld old_world` builds a request and calls `count()`. Nothing changes yet. The staff member reads the size and clicks **Confirm**. 2. `/purgeworld confirm` starts the request it kept. The purge deletes the edits up to that start. When the purge ends, Oasis forgets `old_world` if it is not loaded and holds no edit any more. A later request that names `old_world` fails with `NO_SUCH_WORLD`. # Incidents > Read the problems the store recorded, and clear them, as /oasis incidents does. An incident is a problem the store recorded that needs a person, such as a damaged file it set aside or a write that failed. Staff see the newest one in `/oasis status`, list them with `/oasis incidents`, and clear them with `/oasis incidents clear`. `oasis.incidents()` does the same from a plugin: | Method | Does | | --------- | ------------------------------------------------------------------- | | `list()` | The incidents that are not cleared, oldest first. | | `clear()` | Clears every incident of the list, and returns the ones it cleared. | Both return a `CompletableFuture>` that completes on the main thread. An `Incident` holds `time()`, an `Instant`, and `text()`, which says what went wrong and what to do, in the words the store wrote. ```java oasis.incidents().list().thenAccept(incidents -> { for (Incident incident : incidents) { staff.sendMessage(incident.time() + " " + incident.text()); } }); ``` ## Clearing hides an incident [Section titled “Clearing hides an incident”](#clearing-hides-an-incident) A clear deletes nothing. It hides the incidents from the list, from `/oasis status`, and from the alerts, and the store keeps them. The cairn records each clear with the incidents it hid and who cleared them; a clear from your plugin names your plugin. An incident raised after the clear stays in the list. A purge by age also hides the incidents older than its cut, and the cairn records them as purged, apart from the cleared ones. ## When a call fails [Section titled “When a call fails”](#when-a-call-fails) When the store can’t answer, the future completes exceptionally with an `OasisException`. [When a read fails](/docs/api/reading-history/#when-a-read-fails) lists its reasons and shows how to handle it. # Loading your worlds > Give Oasis a world loader for the worlds your plugin keeps, so a rollback can write into them while nobody is in them. A rollback, a restore or an undo writes the world, so the world must be loaded. Oasis loads a world that is not loaded by itself when the world has a folder that Oasis saw. A world your plugin keeps in a storage of its own, such as a slime world, has no such folder. Neither does a world that needs your plugin’s settings, border or spawn when it loads. For those worlds, register a **world loader**: Oasis then asks your plugin for the world when an operation needs it. ```java oasis.worlds().register(new ArenaLoader(arenas)); ``` Without a loader, an operation with edits in such a world fails at once, before it writes anything, and names the world. ## Write a loader [Section titled “Write a loader”](#write-a-loader) A `WorldLoader` has three methods. Oasis calls each of them on the main thread. | Method | What Oasis asks | | --------------------------------------------- | ----------------------------------------------------------------------------------------------------- | | `boolean owns(String world)` | Is this world yours, loaded or not? Answer at once, from what your plugin holds in memory. | | `CompletableFuture load(String world)` | Load the world, or give it when it is loaded, and keep it loaded until `release`. | | `void release(String world)` | The operation that loaded the world has ended, with its sweeps. Unload it whenever your plugin would. | A plugin that has not read its own data yet, such as just after the server starts, when Oasis may already recover an operation, owns every name it may keep. Its `load` then waits for that data, and fails when the world turns out not to exist. Complete the future of `load` from any thread. The operation waits for it without holding up the server. When the world cannot load, complete the future exceptionally: the operation stops with `Failure.Reason.WORLD_LOAD`, and its message names your plugin, the world and the message of your exception. ```java public final class ArenaLoader implements WorldLoader { private final Arenas arenas; public ArenaLoader(Arenas arenas) { this.arenas = arenas; } @Override public boolean owns(String world) { return arenas.exists(world); } @Override public CompletableFuture load(String world) { // The idle unload of your plugin leaves a held arena loaded. arenas.hold(world); return arenas.open(world).whenComplete((loaded, failure) -> { if (failure != null) { arenas.letGo(world); } }); } @Override public void release(String world) { arenas.letGo(world); } } ``` ## The hold [Section titled “The hold”](#the-hold) Before an operation writes into a world you own, loaded or not, Oasis calls `load` once. When the operation and its sweeps end, Oasis calls `release` once for each `load` that completed with the world. A `load` that failed gets no `release`. Between the two, keep the world loaded, even when the last player leaves it. An operation that finds its world unloaded stops with `Failure.Reason.PASS`. If your plugin unloads worlds that stand empty, count a held world as busy, as the example does with `hold` and `letGo`. Oasis never unloads a world that a loader gave it. An operation with edits in several worlds holds each of your worlds once, for the whole operation. A world that is already loaded is held too, so a player who leaves it during a long rollback does not let your plugin unload it under the rollback. ## Which loader [Section titled “Which loader”](#which-loader) For each world of an operation, Oasis asks the loaders in the order they were registered, and the first that owns the name loads it. A world that no loader owns loads from its folder. Oasis never writes a world that a loader owns through the turbo lane. Turbo writes unloaded chunks into region files, and your plugin reads its own storage. Every chunk of such a world takes the live route, even in a turbo operation. ## Worlds nobody can load [Section titled “Worlds nobody can load”](#worlds-nobody-can-load) Before an operation starts, Oasis checks every world it would write into: the worlds the selection names, or, when it names none, each world where the selection has edits to write. A world that is not loaded, that no loader owns, and that has no folder Oasis can load, is one nobody can load. The operation then fails with `Failure.Reason.WORLD_GONE`. It writes nothing, in that world or in any other, and `Failure.message()` names each such world. Name the worlds you want with [`in`](/docs/api/selections/#where), or leave the world out with [`not().in(name)`](/docs/api/selections/#leaving-edits-out), to run the rest. A world name that no world, no record of Oasis and no edit of the store knows fails with `Failure.Reason.NO_SUCH_WORLD` instead. That is a typo, and nothing ran. A staff member who types `/rollback` with no `in` gets the same refusal, with the same names. ## Register and unregister [Section titled “Register and unregister”](#register-and-unregister) `oasis.worlds()` has two methods. | Method | What it does | | -------------------------------- | -------------------------------------------------------------------------------------------------------------- | | `register(WorldLoader loader)` | Oasis asks this loader for every operation from now on. A loader registered twice counts once. | | `unregister(WorldLoader loader)` | Oasis stops asking this loader. An operation that holds a world through it still calls `release` when it ends. | Register your loader once, in `onEnable`. Oasis drops every loader of your plugin when your plugin disables. A plugin that turns a part of itself off while it stays enabled, such as a module, unregisters that part’s loader itself. # Events > Listen for each chunk an operation rewrites, and for the end of every operation and every purge, whoever started it. Oasis fires three Bukkit events, in `com.oasis.api.event`. Listen to them as to any Bukkit event. You don’t need `Oasis.api(this)` to listen. | Event | Fires when | | --------------------- | ------------------------------------ | | `OperationChunkEvent` | An operation is done with one chunk. | | `OperationEndEvent` | An operation ends, with its sweeps. | | `PurgeEndEvent` | A purge ends. | All three fire on the main thread. The two operation events fire for **every** operation: one a staff member typed, one a plugin started, and one a recovery finished at startup. That makes them different from `Operation.result()`, which only your own operations have. `PurgeEndEvent` fires for every purge in the same way. Oasis fires these events, and a plugin only listens to them. Never construct or call one yourself. ## OperationChunkEvent [Section titled “OperationChunkEvent”](#operationchunkevent) Oasis fires `OperationChunkEvent` once for each chunk of each pass, when the pass is done with it: a rollback, a restore, an undo, a sweep, and a recovery at startup. | Method | Returns | | ---------------------- | ---------------------------------------------------------------------------------------------------------------------- | | `operation()` | The id of the pass. A sweep has an id of its own. `undo` of any pass id reverses the whole operation. | | `requester()` | Who asked for the operation: a player, the console’s id, or a plugin’s id. | | `type()` | What the operation does: `OperationType.ROLLBACK`, `RESTORE` or `UNDO`. A sweep has the type of its operation. | | `world()` | The world of the chunk. | | `chunkX()`, `chunkZ()` | The chunk coordinates, in chunks. | | `complete()` | True when the pass wrote every edit it chose in this chunk. False when a cancel or a failure ended the chunk part way. | The chunk changed either way, so treat a `complete()` of false as a change too. ### Recipe: redraw a map [Section titled “Recipe: redraw a map”](#recipe-redraw-a-map) An operation writes its blocks with no block events. A map plugin that redraws a chunk on `BlockPlaceEvent` or `BlockBreakEvent` never hears about a rollback, and its map still shows the grief. Listen for `OperationChunkEvent` and ask the map to redraw the chunk instead. A big rollback can rewrite thousands of chunks, and a sweep can visit a chunk again. So this listener collects the chunks in a set, and hands them to the map once a second. ```java package com.example.mapredraw; import com.oasis.api.event.OperationChunkEvent; import java.util.LinkedHashSet; import java.util.Set; import java.util.UUID; import org.bukkit.Bukkit; import org.bukkit.World; import org.bukkit.event.EventHandler; import org.bukkit.event.Listener; import org.bukkit.plugin.java.JavaPlugin; public final class MapRedraw extends JavaPlugin implements Listener { /** The one call into your map plugin: redraw this chunk. */ public interface ChunkRenderer { void render(World world, int chunkX, int chunkZ); } private record ChunkKey(UUID world, int x, int z) { } private final Set changed = new LinkedHashSet<>(); private ChunkRenderer renderer; @Override public void onEnable() { // Replace this with the call of your map plugin. See the note below. renderer = (world, chunkX, chunkZ) -> getLogger().info("Redraw " + world.getName() + " " + chunkX + ", " + chunkZ); getServer().getPluginManager().registerEvents(this, this); getServer().getScheduler().runTaskTimer(this, this::flush, 20L, 20L); } @EventHandler public void onChunk(OperationChunkEvent event) { changed.add(new ChunkKey(event.world().getUID(), event.chunkX(), event.chunkZ())); } /** Once a second, on the main thread: redraw each changed chunk once. */ private void flush() { for (ChunkKey key : changed) { World world = Bukkit.getWorld(key.world()); if (world != null) { renderer.render(world, key.x(), key.z()); } } changed.clear(); } } ``` This recipe does not depend on any map plugin. `ChunkRenderer` is the one place your map plugin’s API goes: put its “render this chunk” call in the lambda. If that call is not safe on the main thread, hand the chunks to the thread your map plugin asks for. ## OperationEndEvent [Section titled “OperationEndEvent”](#operationendevent) Oasis fires `OperationEndEvent` when an operation ends, with its sweeps. When it fires, the operation slot is free, so a listener can start the next operation. | Method | Returns | | ------------- | ------------------------------------------------------------------------------------------------------------------------------------ | | `operation()` | An `OptionalLong`: the id of the first pass, which names the operation. Empty when it failed before history recorded its first pass. | | `requester()` | Who asked for the operation: a player, the console’s id, or a plugin’s id. | | `type()` | What the operation did: `OperationType.ROLLBACK`, `RESTORE` or `UNDO`. | | `result()` | How it ended, as a [`Result`](/docs/api/operations/#the-result). | | `recovered()` | True when a recovery at startup finished this operation after a crash or a stop. | `result()` is the same `Result` that `Operation.result()` gives the plugin that started it, with the same counts and the same `duration()`. There is one difference: its `forceKept()` is always empty. A force request belongs to the plugin that started the operation, and staff force a typed operation with its link. The event fires only for an operation that took the slot. An operation that never ran fires none. That covers an API operation that a cancel ended before its pass, and one that Oasis refused before it reached the slot, such as a start that found another operation running (`BUSY`) or one with a world nobody can load (`WORLD_GONE`). ### Recipe: report operations to Discord [Section titled “Recipe: report operations to Discord”](#recipe-report-operations-to-discord) This plugin posts a line to a Discord webhook for every operation that ends. It names the operation with `type()`, and who asked with `oasis.key()`, which knows plugins and actors, and falls back to the player’s name. RollbackReporter.java ```java package com.example.reporter; import com.oasis.api.Failure; import com.oasis.api.Oasis; import com.oasis.api.OasisApi; import com.oasis.api.Result; import com.oasis.api.event.OperationEndEvent; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.util.UUID; import org.bukkit.Bukkit; import org.bukkit.event.EventHandler; import org.bukkit.event.Listener; import org.bukkit.plugin.java.JavaPlugin; public final class RollbackReporter extends JavaPlugin implements Listener { private final HttpClient http = HttpClient.newHttpClient(); private OasisApi oasis; private URI webhook; @Override public void onEnable() { String url = getConfig().getString("discord-webhook", ""); if (url.isBlank()) { getLogger().warning("Set discord-webhook in config.yml to report operations."); return; } webhook = URI.create(url); oasis = Oasis.api(this); getServer().getPluginManager().registerEvents(this, this); } @EventHandler public void onEnd(OperationEndEvent event) { Result result = event.result(); String who = name(event.requester()); String number = event.operation().stream().mapToObj(id -> " #" + id).findFirst().orElse(""); String what = switch (event.type()) { case ROLLBACK -> "rollback"; case RESTORE -> "restore"; case UNDO -> "undo"; }; String line = switch (result.outcome()) { case FINISHED -> String.format("%s's %s%s finished: %d edits written, %d kept, %d left, in %ds.", who, what, number, result.applied(), result.kept(), result.left(), result.duration().toSeconds()); case CANCELLED -> String.format("%s's %s%s was cancelled after %d edits.", who, what, number, result.applied()); case FAILED -> String.format("%s's %s failed: %s", who, what, result.failure().map(Failure::message).orElse("no reason given")); }; if (event.recovered()) { line += " A recovery at startup finished it."; } post(line); } /** A plugin or an actor by its key, such as #plugin:GriefRevert or #console, or a player by name. */ private String name(UUID person) { return oasis.key(person).orElseGet(() -> { String player = Bukkit.getOfflinePlayer(person).getName(); return player == null ? person.toString() : player; }); } /** Sends the line off the main thread. The listener never waits for Discord. */ private void post(String content) { String json = "{\"content\":\"" + escape(content) + "\"}"; HttpRequest request = HttpRequest.newBuilder(webhook) .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(json)) .build(); http.sendAsync(request, HttpResponse.BodyHandlers.discarding()).whenComplete((response, error) -> { if (error != null) { getLogger().warning("Discord did not take the report: " + error.getMessage()); } else if (response.statusCode() >= 300) { getLogger().warning("Discord answered " + response.statusCode() + " to the report."); } }); } private static String escape(String text) { StringBuilder out = new StringBuilder(); for (char c : text.toCharArray()) { switch (c) { case '"' -> out.append("\\\""); case '\\' -> out.append("\\\\"); case '\n' -> out.append("\\n"); default -> out.append(c); } } return out.toString(); } } ``` Because the event fires for every operation, this one listener reports the rollbacks staff type, the ones other plugins start, and the ones a recovery finishes after a crash. `oasis.key()` knows a plugin’s key only when that plugin asked for the API since the server started. For a plugin that hasn’t, and for an id Oasis doesn’t know, the recipe falls back to the raw id. ## PurgeEndEvent [Section titled “PurgeEndEvent”](#purgeendevent) Oasis fires `PurgeEndEvent` when a purge ends: one a staff member typed with `/oasis purge`, and one a plugin started with [`oasis.purges()`](/docs/api/purges/). When it fires, the purge slot is free, so a listener can start the next purge. | Method | Returns | | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `requester()` | Who asked for the purge: a player, the console’s id, or a plugin’s id. | | `result()` | How it ended, as a [`PurgeResult`](/docs/api/purges/#the-result): `outcome()`, `failure()`, `deleted()` and `duration()`. A purge that began fails only with `Failure.Reason.STORE`, and `deleted()` then holds the edits it deleted before the failure. | A purge that Oasis refused before it began fires no event. That covers a start that found another purge or an operation running, a start while the store rebuilds or moves, and a selection with a world that does not exist. The `Purge.result()` of such a purge ends `Outcome.FAILED` with the reason. A purge of worlds forgets the worlds that are not loaded and hold no edit any more before the event fires. A request that names such a world fails with `NO_SUCH_WORLD`. ```java @EventHandler public void onPurge(PurgeEndEvent event) { String who = oasis.key(event.requester()).orElse(event.requester().toString()); PurgeResult result = event.result(); if (result.failure().isPresent()) { getLogger().warning("The purge of " + who + " failed: " + result.failure().get().message()); return; } getLogger().info("The purge of " + who + " deleted " + result.deleted() + " edits."); } ``` # Vocabulary > Every Kind, Cause, Actor, OperationType, Phase, Outcome and Failure.Reason in the Oasis API, with what each one means. These tables list every value of the API’s enums. They come from the enums themselves, so the words, keys and flags are exact. Each enum’s `description()` is the short text Oasis shows players, and you can show it too. ## Kind [Section titled “Kind”](#kind) What an edit describes. `word()` is the stored word, and `plural()` is the word staff type after `kind` in a command. `Kind.of(String)` finds a kind by either one, in any case. Only kinds with `canRollBack()` change the world in an operation. The rest are for lookups. Only kinds with `inspectable()` describe the world at a place, so only they appear in [`lookups().history(block)`](/docs/api/reading-history/#the-history-of-one-block). | Kind | `word()` | `plural()` | `canRollBack()` | `inspectable()` | `description()` | | ----------- | ----------- | ------------ | --------------- | --------------- | -------------------------------------------------------- | | `BLOCK` | `block` | `blocks` | Yes | Yes | block changes | | `CONTAINER` | `container` | `containers` | Yes | Yes | items moved in chests and other containers | | `ENTITY` | `entity` | `entities` | Yes | Yes | mobs, armor stands, vehicles, paintings, and item frames | | `ITEM` | `item` | `items` | No | Yes | items dropped and picked up | | `BOOK` | `book` | `books` | No | No | books written and signed | | `CHAT` | `chat` | `chat` | No | No | chat messages | | `COMMAND` | `command` | `commands` | No | No | commands run | | `SESSION` | `session` | `sessions` | No | No | logins and logouts | | `RENAME` | `rename` | `renames` | No | No | items named in an anvil | ## Cause [Section titled “Cause”](#cause) What happened. `word()` is the stored word, and the word staff type after `cause` in a command. `Cause.of(String)` finds a cause by its word, in any case. The logger throws `IllegalArgumentException` for `ROLLBACK`, `UNDO` and `REDO`, because only Oasis writes them. See [Causes only Oasis writes](/docs/api/logging-edits/#causes-only-oasis-writes). A selection may name them. | Cause | `word()` | `description()` | | ------------- | ------------- | -------------------------------------------------------------------------------------------- | | `BREAKING` | `breaking` | a block, an armor stand, or a vehicle was broken | | `PLACING` | `placing` | a player placed a block | | `EXPLOSION` | `explosion` | TNT, creepers, beds, and crystals | | `FIRE` | `fire` | fire destroyed a block | | `IGNITION` | `ignition` | fire lit by a player, lava, or lightning | | `FLOW` | `flow` | water or lava flowed into a block | | `MELTING` | `melting` | ice or snow melted, coral died | | `FORMING` | `forming` | ice, snow, or concrete formed | | `SPREADING` | `spreading` | fire, grass, mushrooms, or vines spread | | `DECAY` | `decay` | leaves decayed | | `GROWTH` | `growth` | crops, saplings, or trees grew | | `PISTON` | `piston` | a piston moved a block | | `BUCKET` | `bucket` | water or lava poured or scooped up with a bucket, or poured by a dispenser | | `SIGN` | `sign` | text written on a sign | | `WRITING` | `writing` | a book written or signed | | `INTERACTION` | `interaction` | a door, gate, trapdoor, button, or lever used | | `WORLDEDIT` | `worldedit` | WorldEdit or FAWE | | `KILLING` | `killing` | a mob or an animal was killed | | `SPAWNING` | `spawning` | a boat, minecart, or armor stand was placed, or a mob came from an egg, breeding, or a split | | `HANGING` | `hanging` | a painting or item frame was placed or broken | | `FRAMING` | `framing` | an item put in, turned, or taken out of an item frame | | `EQUIPPING` | `equipping` | armor or an item put on or taken off an armor stand | | `TAKING` | `taking` | items taken out of a container | | `PUTTING` | `putting` | items put into a container | | `DROPPING` | `dropping` | an item dropped by a player | | `PICKUP` | `pickup` | an item picked up by a player | | `CHAT` | `chat` | a chat message | | `COMMAND` | `command` | a command run by a player | | `LOGIN` | `login` | a player joined | | `LOGOUT` | `logout` | a player left | | `ROLLBACK` | `rollback` | a rollback, a restore, or an undo put a block back | | `UNDO` | `undo` | FAWE undid a WorldEdit edit | | `REDO` | `redo` | FAWE redid a WorldEdit edit | | `RENAMING` | `renaming` | an item named in an anvil | ## Actor [Section titled “Actor”](#actor) Who acted when no player did. Each actor has a stable `id()` and a `key()` that starts with `#`, so staff can name it after `user` like any player. The id is the same on every server: Oasis makes it from the key. `Actor.of(String)` finds an actor by its key, and `Actor.of(UUID)` by its id, which is empty for a player. `isWorld()` is true when the actor is the world acting and not a person. A rollback looks through an edit of one of these: water, fire and decay are not someone who changed the block. WorldEdit from the console, the rollback actor and the console itself are not world actors. A world actor can carry an edit: `EditLog.via()` takes it, and an edit of it with nobody behind it reads it as both `person()` and `actor()`. The other three act as a person and carry nothing, so `via()` throws for them. | Actor | `key()` | `isWorld()` | `description()` | `id()` | | ------------- | -------------- | ----------- | ---------------------------------------------------------------- | -------------------------------------- | | `WATER` | `#water` | Yes | water flow | `238b436a-60e8-3749-924f-cf3a986a00eb` | | `LAVA` | `#lava` | Yes | lava flow | `fd82c6ab-0c1f-3d49-8ec5-ad6218e7986e` | | `FIRE` | `#fire` | Yes | fire | `c7ea9c8a-104d-35f7-84c6-9754715d1a7a` | | `EXPLOSION` | `#explosion` | Yes | explosions with no player behind them: beds, crystals, fireballs | `a771dc97-8eff-3082-a086-99cd3bb7ae99` | | `CREEPER` | `#creeper` | Yes | creepers | `f06e198d-42c6-309f-87a4-9d8a40b9fba9` | | `DISPENSER` | `#dispenser` | Yes | items dispensed into the world by dispensers or droppers | `4ae65d41-38e3-3c1b-a932-c5f9fa8b332f` | | `DECAY` | `#decay` | Yes | leaves decaying | `115faa9c-99cd-3bd5-a6aa-6da6a711614f` | | `FALLING` | `#falling` | Yes | sand, gravel, and anvils falling | `fb3c7253-7915-3893-b5f0-5301439d5305` | | `GROWTH` | `#growth` | Yes | plants and trees growing | `3e78147c-b7ec-3bd7-8a82-c1c21bdd2581` | | `PISTON` | `#piston` | Yes | pistons | `1abe6017-5805-36d9-8207-7db76ac4e52f` | | `HOPPER` | `#hopper` | Yes | items transferred between inventories by hoppers or droppers | `27873a62-68ef-3369-a038-c15c6bc3f3ab` | | `ENVIRONMENT` | `#environment` | Yes | everything else the world did on its own | `3cdb86bf-e001-33c8-9291-8cf6933538cd` | | `WORLDEDIT` | `#worldedit` | No | WorldEdit from the console | `0d38e07b-7cd8-37b7-b55c-2302d942cd15` | | `ROLLBACK` | `#rollback` | No | a rollback, a restore, or an undo Oasis ran | `d39c8832-29fc-3f6f-8f52-11598f4404c9` | | `CONSOLE` | `#console` | No | the console | `c7d56188-2b63-3bb4-b321-c5e2b6afd770` | A plugin that asked for the API also has a key and an id, `#plugin:`. It is not an `Actor` value. Read it with [`oasis.id()` and `oasis.key(person)`](/docs/api/concepts/#what-the-api-acts-for). ## OperationType [Section titled “OperationType”](#operationtype) What an operation does, as `Operation.type()`, `OperationChunkEvent.type()` and `OperationEndEvent.type()` read it. A sweep and a force have the type of the operation they follow. | OperationType | Means | | ------------- | -------------------------------------------------------------------- | | `ROLLBACK` | It puts back what the chosen edits changed, as it stood before them. | | `RESTORE` | It puts the chosen edits back after a rollback took them away. | | `UNDO` | It reverses an earlier operation and the passes of its group. | ## Phase [Section titled “Phase”](#phase) The stage an operation is in, as `Progress.phase()` reads it. | Phase | Means | | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | | `PLANNING` | The operation reads what it must do. `Progress.fraction()` reads 0, and `chunks()` is 0. | | `APPLYING` | The first pass writes the world, a chunk at a time. | | `SWEEPING` | A follow-up pass of the same operation takes the lava, the water and the fire the world made while the first pass ran, which that pass could not select. | | `FINISHED` | The operation and its sweeps ended. | ## Outcome [Section titled “Outcome”](#outcome) How an operation or a purge ended, as `Result.outcome()` and `PurgeResult.outcome()` read it. A purge never ends `CANCELLED`. | Outcome | Means | | ----------- | ------------------------------------------------------------------------------------------------- | | `FINISHED` | The operation ran to its end. | | `CANCELLED` | `Operation.cancel()` stopped it. `Result.applied()` counts what it wrote before it stopped. | | `FAILED` | It could not run, or stopped on an error. `Result.failure()` or `PurgeResult.failure()` says why. | ## Failure.Reason [Section titled “Failure.Reason”](#failurereason) Why an operation or a purge ended with `Outcome.FAILED`, as `Result.failure().get().reason()` or `PurgeResult.failure().get().reason()` reads it, or why a read could not answer, as `OasisException.failure().reason()` reads it. Test the reason in code, and show `Failure.message()` to people. | Reason | Means | | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `BUSY` | Another operation runs, or a purge runs, or an operation runs that can hold the edits of your purge. Oasis runs one at a time; start yours again when it ends. | | `REBUILDING` | The store rebuilds the runs that another build of Oasis wrote, after an update. Start it again when the rebuild ends. | | `MOVING` | The history moves to another store (`/oasis move`). Start it again when the move ends. | | `STARTING` | Oasis still opens its store, just after the server started. Start it again shortly. | | `OVERLOADED` | The store holds as many requests as it takes. Start it again shortly. | | `NOTHING_TO_UNDO` | The undo names a person who has run no operation. | | `NO_SUCH_OPERATION` | The undo, or the read of kept positions, names an operation that the history does not hold. | | `STORE` | The store failed: Oasis stopped logging, or the store could not read or record the operation, or a purge stopped on a store error. The operation or the purge does not run again by itself; the server log names the error. | | `PASS` | The pass stopped on an error after it began to write: the store failed under it, or a write to the world threw. The edits it wrote stay, and `Result.applied()` counts them. | | `NO_SUCH_WORLD` | The selection names a world, chosen or left out, that is not loaded under that exact name, that Oasis never saw, and that the history holds no edit of. Most often a typo. Nothing ran, and the message names the world. | | `WORLD_GONE` | The operation has edits in a world that is not loaded, that no [world loader](/docs/api/worlds/) owns, and that has no folder Oasis can load. It wrote nothing. The message names each such world. | | `WORLD_LOAD` | A world of the operation did not load: its world loader failed, or its folder did not open. The edits written before stay, and `Result.applied()` counts them. The message names the world and why. | | `WORLD_FORMAT` | A newer build of Oasis or Packs Server wrote values into a world of the operation, in a format this build does not know. The pass writes nothing into that world. Install that build or a newer one; the message names it. | [Running operations](/docs/api/operations/#when-an-operation-fails) says what to do about each one. A purge ends with `BUSY`, `NO_SUCH_WORLD`, `REBUILDING`, `MOVING`, `STARTING`, `OVERLOADED` or `STORE`. See [When a purge fails](/docs/api/purges/#when-a-purge-fails). A read, such as a lookup or an estimate, fails with `NO_SUCH_WORLD`, `STARTING`, `OVERLOADED`, `MOVING` or `STORE`, the read of kept positions also with `NO_SUCH_OPERATION`, and an estimate of an undo also with `NOTHING_TO_UNDO` or `NO_SUCH_OPERATION`. See [When a read fails](/docs/api/reading-history/#when-a-read-fails). # For AI agents > Give your AI coding agent the Oasis API docs as plain text, or install the oasis-api skill. Oasis is new, so AI coding agents don’t know it. An agent that hasn’t read the docs guesses at an API it has never seen, and the code it writes won’t compile. Give it the real API: this site serves the docs as plain text, and the `oasis-api` skill teaches an agent the API in one file. ## The docs as plain text [Section titled “The docs as plain text”](#the-docs-as-plain-text) | URL | Holds | | ------------------------------------------ | ----------------------------------------------------------------------------- | | [`/llms.txt`](/llms.txt) | An index of the site, with a link to each set below. Start here. | | [`/llms-full.txt`](/llms-full.txt) | Every page of the site, in full. | | [`/llms-small.txt`](/llms-small.txt) | Every page of the site, shortened. | | [`/_llms-txt/api.txt`](/_llms-txt/api.txt) | The developer API pages only. The best fit for an agent that writes a plugin. | Every page also has a Markdown copy at its URL with `.md` added. This page is at [`/docs/api/ai-agents.md`](/docs/api/ai-agents.md), and [Running operations](/docs/api/operations/) is at [`/docs/api/operations.md`](/docs/api/operations.md). Each HTML page also points to its copy with a `` tag, so an agent that reads the page can find it. To give an agent the whole API, paste this into your prompt: ```plaintext Read https://oasis-protection.com/_llms-txt/api.txt before you write any code that uses the Oasis API. ``` ## The oasis-api skill [Section titled “The oasis-api skill”](#the-oasis-api-skill) The `oasis-api` skill teaches an agent the Oasis API in one file. Your agent loads it when a task needs block logging, rollbacks or lookups on a server that runs Oasis. The skill changes with the API, so install it in a way that keeps it current. ### Claude Code [Section titled “Claude Code”](#claude-code) Add the Oasis marketplace and install the plugin: ```plaintext /plugin marketplace add oasis-protection/skills /plugin install oasis-api@oasis ``` Then turn on updates, once: run `/plugin`, open **Marketplaces**, select `oasis`, and select **Enable auto-update**. Claude Code then brings in each new version of the skill when a session starts. ### Codex [Section titled “Codex”](#codex) ```sh codex plugin marketplace add oasis-protection/skills codex plugin add oasis-api@oasis ``` Run `codex plugin marketplace upgrade oasis` to bring in a new version. ### Any other agent [Section titled “Any other agent”](#any-other-agent) For Cursor and the other agents that read skills, install it with the `skills` tool: ```sh npx skills add oasis-protection/skills --skill oasis-api ``` The `skills` tool doesn’t update a skill on its own. Run `npx skills update` now and then to get the newest version. A skill installed this way reminds you: your agent tells you to run the command, and never runs it for you. All three install the same skill from the [`oasis-protection/skills`](https://github.com/oasis-protection/skills) repository. ### What the skill teaches [Section titled “What the skill teaches”](#what-the-skill-teaches) * **Depend on it.** The repository, the coordinates, `compileOnly` or `provided`, `depend` or `softdepend`, and `Oasis.api(this)` in `onEnable`. * **Read history.** `find`, `history`, `kept`, pages of eight, and `next()`. How to build a `Selection`, leave edits out with `not()`, why an empty list throws, and how to handle a read that fails with an `OasisException`. * **Log an edit.** The `EditLog` methods, which `block` form keeps a chest’s items, which forms read on the calling thread, and the causes only Oasis writes. * **Run an operation.** Requests, `requestedBy`, `audience`, `estimate()`, `start()`, `type()`, kept and left, `forceKept()`, cancel, one operation at a time, and the failure reasons. * **Purge history.** Requests, `count()`, `start()`, a purge of whole worlds, a world that is not loaded, one purge at a time, and the failure reasons. * **Load the worlds a plugin keeps.** A `WorldLoader` with `owns`, `load` and `release`, the hold between `load` and `release`, and `WORLD_GONE` for a world nobody can load. * **Threads.** Every future completes on the main thread, the logger takes edits from any thread, and an agent never calls `join()` on the main thread. * **Nothing counts.** Why a page has no total, and where a size comes from instead. * **The person and the actor.** What each one is, and how the logger and the reader use the same two words. * **The vocabulary.** The kinds, causes and actors, and the rule to start an operation rather than change the world back by hand.