2026-10-05 09:50:05 -05:00
2026-10-05 09:50:05 -05:00

QBit: Quick Search

Version License Java

OpenSearch-backed full-text search across the tables of a QQQ application.

Annotate entity classes or declare tables in config, point the QBit at an OpenSearch cluster, produce it into your QInstance, and you get real-time indexing of committed writes, a scheduled catch-up and reconcile, a blackout-free full rebuild, and a permission-aware search API with relevance scoring, typeahead, highlights and pagination.

Requires QQQ 4.1 (the QBit uses 4.1's record-change listeners, after-commit callbacks and runtime services). Serving core search (POST /qqq/v1/search, below) needs QQQ 4.1.0-RC.2 or later, the first release that carries the record-search provider SPI. The record developer panel (below) needs QQQ 4.1.0-RC.4 or later, which carries the developer panel contract; the 1.1 line builds against 4.1.0-RC.4 from the next release candidate on.

What it does

Capability How
Real-time indexing One RecordChangeListenerInterface registered on the instance; events are published after the write's transaction commits, so rolled-back writes never reach the index. Failures are recorded in quickSearchFailedEvent and replayed by the scheduled basepull.
Basepull catch-up Scheduled process (schedulerName plus basepullRepeatSeconds) that re-indexes rows changed since the previous run's start, with a configurable overlap, paging by primary key.
Reconcile Scheduled or on-demand process that re-indexes everything in place, then removes documents whose source row is gone, including documents of tables no longer configured. No search blackout.
Full rebuild Indexes every table into a fresh physical index and atomically swaps the alias; the old index keeps serving until the new one is complete. Also upgrades an index created by an older release.
Core search QuickSearchRecordSearchProvider serves QQQ's RecordSearchAction (POST /qqq/v1/search, the Next UI global search box) from OpenSearch for the indexed tables, with a timeout and an automatic fallback to core's own search.
Search QuickSearchAction: AND semantics, edge-ngram typeahead with a plain search analyzer, per-field weights, highlights, tableNames and limitPerTable, bounded paging, tableLabel on results. Only tables the session may read are searched; hits are re-read through QueryAction so record security locks apply.
Connections HTTP or HTTPS, basic auth, AWS IAM SigV4, custom CA or trust store, mTLS, timeouts, pool size, a transport customizer SPI for anything else. Secrets by ${env.X} reference.
Operations quickSearchIndex (per-table status, counts, last run, last error), quickSearchIndexRun (history, purged after runHistoryRetentionDays), quickSearchFailedEvent, a quickSearchAdmin app, explicit permission rules, fail-fast or degraded startup.

Supported platforms

Tested in CI against the Docker images named in pom.xml (opensearch.test.image).

Target Status
Self-managed OpenSearch 2.19.x and 3.x Supported, security plugin disabled or HTTP basic authentication, HTTP or HTTPS with a trusted, configured or (development only) unverified certificate
Amazon OpenSearch Service managed domains, OpenSearch 2.x or 3.x Supported with fine-grained access control: either an internal-database master user with authMode BASIC, or IAM with authMode AWS_SIGV4 (awsRegion, awsServiceName es, optional awsAssumeRoleArn). Port 443 over HTTPS. Keep bulkBatchSize and maxBulkRequestBytes under the instance type's 10 MiB payload limit on small instances.
Amazon OpenSearch Serverless Not supported in 1.0 (_delete_by_query and _refresh, used by reconcile and full rebuild, are not offered there)
Elasticsearch, OpenSearch 1.x Not supported

Least-privilege role for the identity the QBit uses, on the alias and its physical indexes ({indexName}*): create_index, read, write, delete, plus indices:admin/refresh*, indices:admin/aliases, indices:admin/delete (for the alias swap) and indices:admin/mapping/get; cluster level cluster_composite_ops for _bulk. For AWS fine-grained access control map the same to the basic-auth user or the IAM role. Treat the index as sensitive data: it holds copies of every indexed field value.

For AWS_SIGV4 add these optional dependencies to your host (the QBit declares them optional):

<dependency><groupId>software.amazon.awssdk</groupId><artifactId>apache-client</artifactId></dependency>
<dependency><groupId>software.amazon.awssdk</groupId><artifactId>sts</artifactId></dependency> <!-- only for awsAssumeRoleArn -->

Getting started

<dependency>
    <groupId>com.kingsrook.qbits</groupId>
    <artifactId>qbit-quick-search</artifactId>
    <version>1.1.0-RC.1</version>
</dependency>

1.1.0-RC.1 is the current release candidate (the 1.1 line, published from release/1.1; it needs QQQ 4.1.0-RC.2 or later, see Upgrading from 1.0). Hosts staying on the 1.0 line use 1.0.0-RC.2; 1.0.0 follows once host applications have adopted it (#12).

Annotate entity classes:

@QuickSearchable(tableName = "customer")
public class Customer
{
   @QuickSearchField(weight = 3)
   private String name;

   @QuickSearchField(weight = 1)
   private String email;

   @QuickSearchField(weight = 2, includeLabel = true)
   private String accountNumber;
}

Produce the QBit (after your backends, tables and scheduler are in the instance):

QuickSearchQBitConfig config = new QuickSearchQBitConfig()
   .withBackendName("rdbms")
   .withOpensearchUrl("https://search.example.com:443")
   .withAuthMode(QuickSearchAuthMode.BASIC)
   .withOpensearchUsername("${env.OPENSEARCH_USER}")
   .withOpensearchPassword("${env.OPENSEARCH_PASSWORD}")
   .withOpensearchIndexName("my-app-search")
   .withSchedulerName("myScheduler")
   .withSearchableEntityClasses(List.of(Customer.class, Order.class));

new QuickSearchQBitProducer().withConfig(config).produce(qInstance);

The producer implements QQQ's QBitMetaDataProducer, registers its own QBitMetaData (com.kingsrook.qbits:quick-search), and adds its tables, processes and app to the instance. Do not add a QBitMetaData for it yourself.

Tables from other QBits or jars, without annotations:

config.withSearchableTable(new SearchableTableConfig("crmContact", List.of(
      new SearchableFieldConfig("firstName").withWeight(2),
      new SearchableFieldConfig("lastName").withWeight(2),
      new SearchableFieldConfig("email").withWeight(3)))
   .withRecordLabelFormat("%s %s", "firstName", "lastName"));

Annotation and config-driven tables can be mixed; a table may appear in only one of them. Hidden and password fields are never indexed. A table without the change-detection field (modifyDate by default) is covered by reconcile instead of incremental basepull, and the QBit logs a warning for it at startup. Set basepullTimestampField to null on a config-driven table to opt out without the warning.

Searching

QuickSearchOutput output = new QuickSearchAction().execute(new QuickSearchInput()
   .withSearchTerm("widget")
   .withTableNames(List.of("customer", "order"))   // optional
   .withLimit(25)
   .withOffset(0));

for(QuickSearchResult result : output.getResults())
{
   result.getTableName(); result.getTableLabel(); result.getRecordId();
   result.getRecordLabel(); result.getScore(); result.getHighlightSnippet();
}
output.getTotalHits(); output.getTotalHitsIsLowerBound(); output.getHasMore();

limitPerTable returns up to that many hits from each table instead of one ranked list. Terms shorter than 2 characters return nothing; longer than 100 characters are rejected. limit is capped at maxSearchLimit and offset + limit at 10,000.

offset skips N accessible results: with record security locks on, hits a lock hides are not counted, so consecutive pages never overlap or skip a visible hit, and with limitPerTable the offset applies within each table. With locks on, totalHits counts hits confirmed accessible so far; totalHitsIsLowerBound is true when the scan stopped before reading every candidate (page full, result window, or scan budget). A single-list search reads at most 10,000 raw hits in batches of at most 1,000. In per-table mode the T tables share that budget, each getting max(10,000 / T, 2 x limitPerTable) hits, so one call reads at most max(10,000, T x 2 x limitPerTable) raw hits. Paging within a table stops past that share of raw hits, as it does past 10,000 in a single list: hasMore turns false and totalHitsIsLowerBound stays true to signal unreachable matches.

Search runs as the current QContext session: tables the session cannot read are skipped, and with applyRecordSecurityLocks (default) hits are re-read through QueryAction so record security locks apply.

Core search (POST /qqq/v1/search)

QQQ core has its own record search (RecordSearchAction, served at POST /qqq/v1/search and used by the Next UI's global search box): a case-insensitive contains-query over each table's searchFields. With serveCoreRecordSearch (default true) the producer registers QuickSearchRecordSearchProvider on the instance, and core asks it to search every table Quick Search indexes. The API and the UI do not change.

  • What is served from OpenSearch. Tables this QBit indexes and that are enabled in quickSearchIndex. Core offers a table only when it has searchFields, the session may read it, and it supports query, so the producer gives each indexed table that has no searchFields its visible string and integer searchable fields (never hidden or password fields). searchFields you set yourself are kept. A table must be in the QInstance before the QBit is produced to get them.
  • How. One _msearch request with one search per table (Quick Search's query, AND semantics and field weights); each table returns up to 4 x limitPerTable record ids (at most 100) in relevance order. Core re-reads them by primary key through QueryAction, so table permissions, record security locks and record labels are core's, and records deleted since they were indexed drop out.
  • Failure. The OpenSearch request is cancelled after recordSearchTimeoutMillis (default 2500). Any failure (timeout, cluster down, index missing) makes core search those tables with its own contains-query instead, so search degrades but never fails. After a failure, Quick Search claims no tables for 30 seconds, so a down cluster does not add a timeout to every search.
  • Opting out. withServeCoreRecordSearch(false) leaves core search and searchFields untouched. If the host already registered another record-search provider, the QBit keeps it and logs a warning (an instance has one provider).

QuickSearchAction remains the richer API (scores, highlights, pagination, totals).

Processes

Process Trigger What it does
Quick Search Basepull Index Scheduled every basepullRepeatSeconds when schedulerName is set; also on demand Recovers a full reindex that stopped without finishing, replays failed real-time events, re-indexes rows changed since the last run start minus basepullOverlapSeconds, purges old run history
Quick Search Reconcile Index Cron via reconcileCronExpression when schedulerName is set; also on demand (optional tableName input) Re-indexes everything in place, removes documents without a source row and documents of unconfigured tables
Quick Search Full Reindex On demand (optional tableName input) All tables: build a fresh physical index and swap the alias. One table: reconcile algorithm in place. Fails to start while another full reindex is running

Without schedulerName the processes exist but nothing runs them; the QBit logs a warning at startup.

Scheduling

The schedules run only when the host starts through QQQ's QApplicationLauncher (com.kingsrook.qqq.middleware.javalin.QApplicationLauncher, in qqq-middleware-javalin). QApplicationLauncher.run(new MyApplication(), new QApplicationLauncherConfig()) starts the Javalin server, then the QScheduleManager (because the QBit's processes are scheduled), then the instance's runtime services. A host that starts only a server, or builds the QInstance itself, gets the processes but nothing runs them on a schedule, unless it calls QScheduleManager.initInstance(qInstance, systemSessionSupplier).start() itself. qqq.scheduleManager.enabled=false (or QQQ_SCHEDULE_MANAGER_ENABLED=false) turns schedules off on a node.

Basepull's fixed interval runs on any QQQ scheduler. reconcileCronExpression needs a scheduler that supports cron, which in QQQ 4.1 means a QuartzSchedulerMetaData; a SimpleSchedulerMetaData runs fixed intervals only. The expression uses Quartz syntax, seconds first (0 0 3 * * ? is 03:00 every day), and needs reconcileCronTimeZoneId, a Java time zone ID such as UTC or America/Chicago; QQQ has no default zone. produce() rejects a cron without a zone, an invalid expression or zone, and a cron on a scheduler in the instance that does not support cron.

qInstance.addScheduler(new QuartzSchedulerMetaData().withName("quartz").withProperties(quartzProperties));

config.withSchedulerName("quartz")
   .withReconcileCronExpression("0 0 3 * * ?")
   .withReconcileCronTimeZoneId("America/Chicago");

Diagnostic tools

With enableDiagnosticTools (default true) the QBit adds tools to validate the search and debug it from the admin UI. They are table and record actions (never left-nav entries), gated by permissions, recorded in quickSearchDiagnosticEvent, limited to diagnosticRunsPerMinute runs per user and tool, and read-only except Reindex in Search.

Tool Where What it does
Search Playground Quick Search Index, Actions (select rows to pre-fill their tables) Runs a term the way the global search does, as you: the path each table takes (OpenSearch provider, core database search, or not searched) and why; the provider's own request, timed; the global search results you would see; per indexed table, the production query with scores, highlights, matched fields, which hits you can read and which the global search shows, the query DSL and the raw response (hits you cannot read are redacted); the term's tokens; and explain for a hit (table:id). The form stays open, so you can change the term and run again.
Validate Search Index Quick Search Index, Actions; also scheduled by validationCronExpression Cluster and index health, the alias, leftover physical indexes from interrupted rebuilds, the live mapping (version, field types, analyzers, the fieldValues template) and analysis settings, then per table: database rows vs documents, a sample of records checked present and current in the index, a random sample of documents checked to still have a record, the index row's status and last run, and waiting failed events. Each finding is OK, INFO, WARN or FAIL with what to do. The report is saved as a quickSearchValidationRun row (HTML for the record view, JSON for tools). Database reads run as the system user so counts compare like with like; the report holds counts, ids and field names, never record values. One validation runs at a time per server.
Test Search Analyzer Quick Search Index, Actions _analyze on the live index: the tokens of a text as indexed (edge n-grams of 2 to 20 characters) and as a query, and, given a term, whether every term token is among the text's indexed tokens.
Inspect Search Document Every indexed table's records (Actions), and Quick Search Failed Event rows The record's document in the index next to the document indexing would write from the database now, field by field (current, stale or missing), with versions and the raw document. Enter a term to see why the record does or does not match it: term length, table enabled, read permission, document present and current, each term token found or not (and why: one character, longer than 20, not in the text, or only in the database's newer values), explain, and the record's rank against the number of ids the global search takes.
Reindex in Search Every indexed table's records, and Quick Search Failed Event rows Rebuild the selected records' documents from the database now (versioned writes, so a newer document is never overwritten); a record that no longer exists has its document removed; a record you cannot read is skipped. Asks to confirm. Needs the admin permission.
Search Diagnostics widget The Quick Search Admin app home (hosts with their own home add quickSearchDiagnostics) Whether the global search is served by OpenSearch on this server or backing off (and why), failure and fallback counts, the latest search failures from every server, and the latest validation run.

Search failures are recorded too: when the provider's OpenSearch request fails or times out (and core falls back to its own search), or QuickSearchAction's search fails, a SEARCH_FAILED event keeps the tables, duration, error class and message, and the server. The user's term is never stored, only its length. At most 120 events are written per server per minute.

Permissions: the read-only tools and the widget use debugPermissionRules (default quickSearchDebug.hasAccess); Reindex in Search uses adminPermissionRules (quickSearchAdmin.hasAccess); the two history tables use tablePermissionRules (grant .read only: rows are written by the QBit). Impersonation ("search as user X") is deliberately not offered: a fabricated session would not carry the real user's security key values, so its results would mislead.

Record developer panel

With QQQ 4.1.0-RC.4 or later the QBit registers a record developer panel provider (QuickSearchDeveloperPanelProvider, id quickSearch, in QQQ's DeveloperPanelsMetaData). Frontends that render developer panels (the Next UI, /app/{table}/{id}/dev) show a "Quick Search" panel on every record of an indexed table, to users who may read Quick Search Index. Every part is permission-checked:

Part Shown when
Index: enabled, status, record count, last basepull, last full reindex, searchable fields, link to the index row always (read on Quick Search Index)
Latest index run: type, status, start and end, processed, indexed, errors the user may read Quick Search Index Run
Search document: verdict (current, stale, missing) and a Field / Database / Index / Match table enableDiagnosticTools and the debug permission (debugPermissionRules); otherwise a note says why it is missing
Failed index events of the record the user may read Quick Search Failed Event and the record has any
Actions: Inspect search document, Reindex this record the record actions exist and the user may run them (debug and admin permission)
Link to Quick Search Admin the user may open the app

The panel's status is Indexed, Stale, Not indexed, Disabled (the table is turned off in Quick Search Index), or OpenSearch unavailable. When OpenSearch cannot be reached the panel still shows the database-side state, with a warning in place of the document comparison. Hosts without a developer-panel frontend are unaffected.

Configuration reference

Field Default Description
backendName required QQQ backend for the operational tables
opensearchUrl https://host[:port]; preferred over opensearchHost, opensearchPort, useSsl; credentials in the URL are rejected
opensearchHost, opensearchPort, useSsl useSsl=false Legacy connection fields
opensearchIndexName required Alias name owned by this QBit; lowercase, OpenSearch naming rules
authMode inferred NONE, BASIC (inferred when credentials are set), AWS_SIGV4
opensearchUsername, opensearchPassword BASIC credentials; ${env.X} references recommended (an empty value counts as missing); refused over plain HTTP unless allowPlaintextCredentials
awsRegion, awsServiceName, awsAssumeRoleArn region from AWS_REGION; es AWS_SIGV4 settings
tls JVM defaults caCertificatePath or trustStorePath/trustStorePassword/trustStoreType, keyStorePath/keyStorePassword/keyStoreType (mTLS), hostnameVerification (false logs a warning), insecureSkipVerify (loopback only unless allowInsecureInProduction)
connectTimeoutMillis, responseTimeoutMillis, maxConnections 5000, 60000, 30 Transport settings
transportCustomizer QCodeReference to an OpenSearchTransportCustomizer (bearer tokens, API keys, interceptors)
startupMode FAIL_FAST FAIL_FAST fails produce() when the cluster is unreachable; DEGRADED boots and retries on first use
enableRealTimeIndexing true Register the record-change listener
enableBasepullProcess, enableMaintenanceProcesses true Register basepull; register full reindex and reconcile. enableScheduledProcesses is a deprecated alias for both
schedulerName, basepullRepeatSeconds, reconcileCronExpression, reconcileCronTimeZoneId none, 300, none, none Scheduling; a cron needs the time zone ID and a Quartz scheduler, and nothing runs without QApplicationLauncher (see Scheduling)
basepullOverlapSeconds 300 Re-read window before the previous run start
defaultBasepullIntervalMinutes 5 Per-table due interval for config-driven tables
runHistoryRetentionDays 30 Purge run records older than this; null keeps them
fullReindexStaleMinutes 30 A running full reindex that shows no sign of life for this long is treated as stopped: it no longer blocks a new one, and basepull recovers its tables. Keep it well above the time one page of sourceBatchSize records takes; null means the default
bulkBatchSize, maxBulkRequestBytes, sourceBatchSize 500, 5 MiB, 1000 Batch sizes
maxFieldLength 10000 Truncate each indexed value
maxSearchLimit 100 Largest page size
applyRecordSecurityLocks true Re-read hits through QueryAction
serveCoreRecordSearch true Register QuickSearchRecordSearchProvider so core POST /qqq/v1/search uses OpenSearch for indexed tables, and give those tables searchFields when they have none
recordSearchTimeoutMillis 2500 Wall-clock limit on the OpenSearch request behind a core search; past it, core falls back to its own search
enableDiagnosticTools true Add the diagnostic tools, the quickSearchDiagnosticEvent and quickSearchValidationRun tables and the diagnostics widget
enableRecordDiagnosticActions true With the tools on, add Inspect Search Document and Reindex in Search to every indexed table
validationCronExpression, validationCronTimeZoneId none Schedule Validate Search Index; same rules as the reconcile schedule (a cron-capable scheduler such as Quartz, a valid Quartz expression, a time zone ID)
validationSampleSize 50 Records and documents sampled per table by validation (at most 1000)
diagnosticRunsPerMinute 30 Per-user, per-tool limit on diagnostic runs
debugPermissionRules HAS_ACCESS_PERMISSION, base name quickSearchDebug Rules for the read-only diagnostic tools and the widget
adminPermissionRules HAS_ACCESS_PERMISSION, base name quickSearchAdmin Rules for the app, the maintenance processes and Reindex in Search
tablePermissionRules READ_WRITE_PERMISSIONS Rules for the operational tables
tableMetaDataCustomizer Applied to every produced table (backend details, for example)
searchableEntityClasses, searchableTables at least one What to index
indexEventPublisher synchronous Replace the publisher (experimental SPI; see below)

@QuickSearchable(tableName, fields, basepullIntervalMinutes, basepullTimestampField, enabledByDefault) and @QuickSearchField(weight, includeLabel) keep their 0.x meaning.

Operational tables

  • quickSearchIndex: one row per table with enabled (honoured by indexing and search, cached for one minute), basepullIntervalMinutes, lastBasepullTime, lastReconcileTime, lastFullReindexTime, lastRunStatus, lastErrorMessage, recordCount, documentCount, realTimeErrorCount, status (ACTIVE, NEEDS_REINDEX when the configured fields changed since the row was created, or REBUILDING while a full reindex of all tables runs). Unique on tableName.
  • quickSearchIndexRun: runType (BASEPULL, RECONCILE, FULL_REINDEX), status, counts, errorMessage.
  • quickSearchDiagnosticEvent (with enableDiagnosticTools): eventType (SEARCH_FAILED, TOOL_RUN, REINDEX, RATE_LIMITED), source, outcome, userName, tableNames, searchTerm (tool runs only), durationMillis, errorClass, errorMessage (text), detail (text), hostName, createDate. Purged after runHistoryRetentionDays.
  • quickSearchValidationRun (with enableDiagnosticTools): status (PASS, WARN, FAIL), triggerType (MANUAL, SCHEDULED), userName, tableNames, sampleSize, tablesChecked, failCount, warnCount, summary, reportHtml (HTML), reportJson (text), startTime, endTime, createDate. Purged after runHistoryRetentionDays.
  • quickSearchFailedEvent: real-time events that could not be applied; status PENDING or EXHAUSTED after 10 attempts. Deletes made while a full reindex of all tables runs are also recorded here with status AWAITING_REINDEX: the reindex applies them to the new index after the alias swap (basepull does not replay them). While a table is REBUILDING, drift detection is suspended for it. Only one full reindex runs at a time: starting one while another is running fails. A full reindex shows it is running through its FULL_REINDEX run records, which stay RUNNING with modifyDate refreshed after every page. If it is killed mid-run (pod restart, OOM, deploy), recovery is automatic once its run records have shown no sign of life for fullReindexStaleMinutes (30 by default): the next basepull, or the next full reindex, marks those run records FAILED, returns the tables to ACTIVE with lastBasepullTime moved back to the run's start, and turns the AWAITING_REINDEX rows PENDING so basepull replays the deletes. No data is lost. A run that was only slow and is judged stopped notices before its alias swap and fails without swapping, or, after its swap, stops applying its captured deletes; basepull skips a replayed delete whose record exists again.

Extending

IndexEventPublisher lets a host replace the synchronous publisher with a queue. In 1.0 the SPI is experimental: IndexEvent carries the table, record id, action and the record snapshot, but no sequence number, so an asynchronous consumer must order INDEX and DELETE for the same record itself. OpenSearchTransportCustomizer customizes the HTTP transport.

Building

./mvnw test                                 # unit tests (memory backend, mocked client)
CI=true ./mvnw verify                       # plus Testcontainers integration tests and the coverage gate (Docker required)
./mvnw verify -Dopensearch.test.image=opensearchproject/opensearch:3.9.0

Use the wrapper: Maven 3.10 cannot read the published parent POM. The build imports qqq 4.1.0-RC.4 through the default-active qqq-snapshot profile until qbit-build-parent 2.1.0 ships (override with -Dqqq.snapshot.version=...). Without Docker the integration tests are skipped locally; with CI=true they fail instead.

Upgrading from 1.0

See docs/MIGRATION-1.1.md. In short: 1.1 needs the QQQ release with the record-search provider SPI; core search starts using OpenSearch for indexed tables by default, and indexed tables gain searchFields (set serveCoreRecordSearch(false) to keep 1.0 behaviour).

Upgrading from 0.x

See docs/MIGRATION-1.0.md. In short: QQQ 4.1 is required, remove any manual addQBit for this QBit, set schedulerName, prefer opensearchUrl and ${env.} secrets, grant quickSearchAdmin.hasAccess and the table permissions, add the new operational table columns and quickSearchFailedEvent, and run a full reindex once so the index gets the new mapping.

Adopting in another app

Follow Getting started, the Configuration reference and Operational tables (the host application creates the three tables; this QBit ships no DDL), then run a full reindex. Hosts on 1.0 or 0.x should read the upgrade sections above.

See also the QQQ adoption checklist for the full per-feature list (QQQ upgrade, Next dashboard, QBits, migrations, permissions).

License

Apache License, Version 2.0. See LICENSE.

S
Description
QQQ QBit: OpenSearch-backed full-text search across a QQQ app's tables (real-time indexing, reconcile, zero-blackout rebuild, permission-aware search, core /qqq/v1/search provider)
Readme
5.4 MiB
0 Stars 3 Watchers 0 Forks
v0.2.0
Latest
2026-03-30 11:49:29 +00:00
Languages
Java 100%