75: Implement the Nenjim Journal model, text parser, manager and filesystem service
This commit is contained in:
+7
-1
@@ -8,4 +8,10 @@ logs/*.log
|
||||
logs/*.log.gz
|
||||
conf/*.conf
|
||||
conf/*.xml
|
||||
data
|
||||
|
||||
# Ignore all files under data
|
||||
data/**
|
||||
# Except all subdirectories so git will search all those
|
||||
!data/**/
|
||||
# But do NOT ignore those 'example' files
|
||||
!data/**/*.example
|
||||
|
||||
@@ -32,7 +32,6 @@ long
|
||||
milliSeconds
|
||||
Long
|
||||
ContextId
|
||||
JournalId
|
||||
SolanaSlot
|
||||
MoneyAmount
|
||||
SolanaAmount
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# Nenjim Hub public API Journal, format version 1.
|
||||
FORMAT_VERSION=1
|
||||
|
||||
JOURNAL_VERSION=20260816201156858Z
|
||||
|
||||
[METADATA]
|
||||
GROUP=com_r35157_nenjim
|
||||
MODULE=hubd
|
||||
ARTIFACT=api
|
||||
DESCRIPTION='The public API for Nenjim Hub.'
|
||||
INFORMATION_URI='ftp://ftp.r35157.com/nenjim/hubd/readme.txt'
|
||||
|
||||
[POLICY]
|
||||
RECOMMENDED=[0.1.0]:'The best-tested API release'
|
||||
DISCOURAGED=[0.0-->0.0]:'Pre-release design experiments'
|
||||
BLACKLIST=0.0.3:'Known incompatible prototype'
|
||||
|
||||
[RELEASE 0.1.0]
|
||||
LICENSE='Public Domain'
|
||||
PUBLISHED_AT=20260815193000000Z
|
||||
CONTENT_DIGEST=sha256:0000000000000000000000000000000000000000000000000000000000000001
|
||||
CONTENT_DIGEST=cid1:bafkreibxz44g7iprf6gnh44giksyokavlxwicdpqcmqxmoobejhm5grm4a
|
||||
CONTENT_SIZE=123456
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
FORMAT_VERSION=1
|
||||
|
||||
JOURNAL_VERSION=20260816201156858Z
|
||||
|
||||
[METADATA]
|
||||
GROUP=com_r35157_nenjim
|
||||
MODULE=hubd
|
||||
ARTIFACT=impl_ref
|
||||
DESCRIPTION='The reference implementation of Nenjim\'s Hub.'
|
||||
INFORMATION_URI='ipfs:bafkreibxz44g7iprf6gnh44giksyokavlxwicdpqcmqxmoobejhm5grm4a'
|
||||
|
||||
[POLICY]
|
||||
RECOMMENDED=0.1.0:'Reference release for the 0.1 line'
|
||||
DISCOURAGED=[0.0.0-->0.0.9]:'Obsolete implementation experiments'
|
||||
|
||||
[RELEASE 0.1.0]
|
||||
LICENSE='Public Domain'
|
||||
PUBLISHED_AT=20260815194500000Z
|
||||
CONTENT_DIGEST=sha256:0000000000000000000000000000000000000000000000000000000000000002
|
||||
CONTENT_SIZE=234567
|
||||
DEPENDS_ON=nenjim:com_r35157_nenjim-hubd-api=[0.1-->
|
||||
EXCLUDES=nenjim:com_r35157_nenjim-hubd-api=[0.1.1-->0.1.2]:'Known binary incompatibility'
|
||||
PREFERRED=nenjim:com_r35157_nenjim-hubd-api=0.1.0:'Built and reviewed together'
|
||||
@@ -0,0 +1,21 @@
|
||||
FORMAT_VERSION=1
|
||||
|
||||
JOURNAL_VERSION=20260816201156858Z
|
||||
|
||||
[METADATA]
|
||||
GROUP=com_r35157_nenjim
|
||||
MODULE=hubd
|
||||
ARTIFACT=tests
|
||||
DESCRIPTION='The reusable conformance tests for Nenjim Hub.'
|
||||
|
||||
[POLICY]
|
||||
RECOMMENDED=0.1.0:'Current conformance suite'
|
||||
|
||||
[RELEASE 0.1.0]
|
||||
LICENSE='Public Domain'
|
||||
PUBLISHED_AT=20260815200000000Z
|
||||
CONTENT_DIGEST=sha256:0000000000000000000000000000000000000000000000000000000000000003
|
||||
CONTENT_SIZE=3456
|
||||
DEPENDS_ON=nenjim:com_r35157_nenjim-hubd-api=[0.1]
|
||||
PREFERRED=nenjim:com_r35157_nenjim-hubd-api=[0.1.0]:'Primary API target'
|
||||
DEPENDS_ON=nenjim:com_r35157_nenjim-hubd-impl_ref=0.1.0
|
||||
+107
-110
@@ -2,128 +2,125 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Nenjim Documentation</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Nenjim Journal Documentation</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
background-color: #f8f9fa;
|
||||
color: #333;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.container {
|
||||
width: 80%;
|
||||
max-width: 1000px;
|
||||
margin: auto;
|
||||
padding: 20px;
|
||||
background-color: #fff;
|
||||
}
|
||||
h1, h2 {
|
||||
color: #0056b3;
|
||||
}
|
||||
.toc {
|
||||
background: #e7f1ff;
|
||||
padding: 10px 20px;
|
||||
border-left: 4px solid #0056b3;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.footer {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
border-top: 1px solid #ddd;
|
||||
margin-top: 40px;
|
||||
color: #777;
|
||||
}
|
||||
body { font-family: Arial, sans-serif; background: #f8f9fa; color: #263238; margin: 0; }
|
||||
main { max-width: 980px; margin: auto; padding: 2rem; background: white; line-height: 1.55; }
|
||||
h1, h2, h3 { color: #0056b3; }
|
||||
code, pre { background: #f1f3f5; }
|
||||
code { padding: .1rem .25rem; }
|
||||
pre { padding: 1rem; overflow-x: auto; border-left: 4px solid #0056b3; }
|
||||
.status { padding: 1rem; background: #e7f1ff; border-left: 4px solid #0056b3; }
|
||||
table { border-collapse: collapse; width: 100%; }
|
||||
th, td { border: 1px solid #ccd4da; padding: .55rem; text-align: left; vertical-align: top; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Nenjim Documentation</h1>
|
||||
<div class="toc">
|
||||
<h2>Table of Contents</h2>
|
||||
<ul>
|
||||
<li><a href="#1-introduction-to-nenjim">1. Introduction to Nenjim</a></li>
|
||||
<li><a href="#2-versioning-and-dependencies">2. Versioning and Dependencies</a></li>
|
||||
<li><a href="#3-updates-and-security">3. Updates and Security</a></li>
|
||||
<li><a href="#4-payment-system-and-integration-with-assetaz">4. Payment System and Integration with AssetAZ</a></li>
|
||||
<li><a href="#5-backing-store-and-flexibility">5. Backing Store and Flexibility</a></li>
|
||||
<li><a href="#6-standardized-and-automated-package-management">6. Standardized and Automated Package Management</a></li>
|
||||
<li><a href="#7-importance-of-semantic-versioning">7. Importance of Semantic Versioning</a></li>
|
||||
<li><a href="#8-integration-of-plugins-and-central-registry">8. Integration of Plugins and Central Registry</a></li>
|
||||
<li><a href="#9-integration-with-assetaz-and-economic-activity">9. Integration with AssetAZ and Economic Activity</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<h1>Nenjim Documentation</h1>
|
||||
<h2 id="1-introduction-to-nenjim">1. Introduction to Nenjim</h2>
|
||||
<p>Nenjim is an innovative system that enables handling multiple versions of software packages simultaneously.</p>
|
||||
<p>The system solves the challenge of dependencies and versioning by using unique version numbers in the naming of
|
||||
packages,</p>
|
||||
<p>which ensures that multiple versions of the same software package can exist side by side without conflicts.</p>
|
||||
<main>
|
||||
<h1>Nenjim Journals</h1>
|
||||
<p class="status"><strong>Implementation status:</strong> Journal format version 1, the immutable Java model,
|
||||
internal strict parser, read-only <code>JournalService</code>, and explicitly refreshed lifecycle
|
||||
<code>JournalServiceManager</code> are implemented. Contexts, resolution, classloading, downloading, publishing,
|
||||
synchronization, watching, signatures, and license filtering remain future work.</p>
|
||||
|
||||
<h2 id="2-versioning-and-dependencies">2. Versioning and Dependencies</h2>
|
||||
<p>Nenjim uses a method where packages are named with the version number directly in the package name. However,
|
||||
this is managed in so-called journals outside of the actual code, so that the Java code itself does not depend
|
||||
on Nenjim.</p>
|
||||
<p>This ensures that developers do not have to worry about version conflicts, as each dependency refers precisely
|
||||
to the version it needs. The system ensures that all necessary modules are available and kept up to date in
|
||||
the background.</p>
|
||||
<h2>One Journal per artifact</h2>
|
||||
<p>A Journal describes exactly one artifact. API, test, and implementation artifacts therefore have independent
|
||||
Journals and chains. Roles such as API or implementation are not stored as a <code>TYPE</code>.</p>
|
||||
<pre><code><GROUP>-<MODULE>-<ARTIFACT>
|
||||
com_r35157_nenjim-hubd-api
|
||||
com_r35157_nenjim-hubd-api:1.0.3</code></pre>
|
||||
<p>The three metadata components identify the chain; there is no separate <code>JOURNAL_ID</code>.</p>
|
||||
|
||||
<h2 id="3-updates-and-security">3. Updates and Security</h2>
|
||||
<p>Nenjim automatically monitors for updates through what are called journals. If a security vulnerability is
|
||||
discovered in a particular version, the developers update the journal, and all NenjimHubs will automatically
|
||||
fetch the new secure version, or downgrade to a previous version until an update is ready. In this way,
|
||||
updates are distributed quickly and efficiently throughout the entire network.</p>
|
||||
<h2>Complete immutable snapshots and history</h2>
|
||||
<p>Every Journal revision is a complete worldview of the artifact and all releases known in that revision. It is
|
||||
not a delta and inherits nothing implicitly. A later snapshot may change metadata, add or remove releases,
|
||||
correct release knowledge, alter dependencies or policy, or be a semantic no-op.</p>
|
||||
<p>The genesis omits <code>PREVIOUS_JOURNAL_DIGEST</code>. Each successor names the SHA-256 digest of the
|
||||
predecessor's exact UTF-8 text, including whitespace and line endings. A chain has one genesis and one head;
|
||||
missing predecessors, forks, cross-artifact links, and non-increasing Journal versions are invalid.</p>
|
||||
<p><code>JOURNAL_VERSION</code> is when the worldview was published. <code>PUBLISHED_AT</code> is when an artifact
|
||||
release was published. Both use <code>uuuuMMddHHmmssSSS'Z'</code>, but historical knowledge is selected from
|
||||
the Journal chain rather than by filtering the newest snapshot on release publication time.</p>
|
||||
|
||||
<h2 id="4-payment-system-and-integration-with-assetaz">4. Payment System and Integration with AssetAZ</h2>
|
||||
<p>To support payment for software packages, Nenjim integrates with AssetAZ. This means that developers can choose
|
||||
to receive payment for their packages either as a one-time fee, as a subscription, or per use. All transactions
|
||||
are handled via cryptocurrency.</p>
|
||||
<h2>Metadata and release content</h2>
|
||||
<p><code>DESCRIPTION</code> is mandatory single-quoted text. <code>INFORMATION_URI</code> is optional single-quoted
|
||||
metadata and must decode to a non-empty absolute URI. Format 1 does not restrict schemes: HTTPS, FTP, IPFS,
|
||||
and other syntactically valid schemes are accepted without opening or interpreting their content.</p>
|
||||
<p>Every exact <code>major.minor.patch</code> release has a mandatory single-quoted <code>LICENSE</code>, a
|
||||
publication timestamp, non-negative byte size, and one or both supported content identities:
|
||||
<code>sha256</code> and CIDv1 (<code>cid1</code>). License text is preserved as free text; no vocabulary,
|
||||
compatibility decision, or filtering is applied.</p>
|
||||
|
||||
<h2 id="5-backing-store-and-flexibility">5. Backing Store and Flexibility</h2>
|
||||
<p>Nenjim provides complete flexibility regarding where data is stored. This can be via IPFS, in a local folder,
|
||||
or even in a database. The system is designed to be so flexible that developers can choose the solution that
|
||||
best fits their needs.</p>
|
||||
<h2>Version expressions</h2>
|
||||
<table>
|
||||
<thead><tr><th>Expression</th><th>Meaning</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><code>1.0.3</code>, <code>[1.0.3]</code></td><td>The same exact release.</td></tr>
|
||||
<tr><td><code>[1.0]</code></td><td>Every stable 1.0.x release; not exact 1.0.0.</td></tr>
|
||||
<tr><td><code>[1.0.3-->1.0.7]</code></td><td>A closed range including both exact endpoints.</td></tr>
|
||||
<tr><td><code>[1.0-->1.2)</code></td><td>Includes 1.0.x and 1.1.x, excluding the complete 1.2.x series.</td></tr>
|
||||
<tr><td><code>[1.0--></code></td><td>Starts at 1.0.0 and stops before 2.0.0.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p>Comma-separated expressions form a union. Boundaries require at least major and minor. Mathematical
|
||||
comparison syntax, incomplete bare exact versions, prereleases, build metadata, empty elements, and reversed
|
||||
or empty ranges are rejected.</p>
|
||||
|
||||
<h2 id="6-standardized-and-automated-package-management">6. Standardized and Automated Package Management</h2>
|
||||
<p>One of the major advantages of Nenjim is that users no longer have to manually search for dependencies on the
|
||||
Internet. Nenjim uses a standardized method to automatically find and download packages, for example via IPFS.
|
||||
This means that all packages are easily accessible and can be retrieved in a consistent manner, which saves
|
||||
time and ensures a more streamlined experience for both developers and users.</p>
|
||||
<h2>Dependencies</h2>
|
||||
<p>Dependencies belong to individual releases and always have an explicit scheme. Format 1 implements only the
|
||||
<code>nenjim:</code> scheme; Maven resolution and unknown schemes are not silently accepted.</p>
|
||||
<pre><code>DEPENDS_ON=nenjim:com_r35157_nenjim-hubd-api=[1.0-->
|
||||
EXCLUDES=nenjim:com_r35157_nenjim-hubd-api=1.0.5:'Known incompatibility'
|
||||
PREFERRED=nenjim:com_r35157_nenjim-hubd-api=1.0.3:'Built together'</code></pre>
|
||||
<p><code>DEPENDS_ON</code> is the hard base set. <code>EXCLUDES</code> removes hard-invalid combinations for that
|
||||
relationship. <code>PREFERRED</code> is a hint and must remain a subset of
|
||||
<code>DEPENDS_ON - EXCLUDES</code>. Repeated rules form unions while each line and description remains
|
||||
inspectable. The Journal layer represents these rules but does not resolve a graph.</p>
|
||||
|
||||
<h2 id="7-importance-of-semantic-versioning">7. Importance of Semantic Versioning</h2>
|
||||
<p>For Nenjim to function optimally, it is essential that all packages follow the principles of semantic versioning.
|
||||
This means that each version of a package clearly indicates whether it is a minor update, a bug fix, or a major,
|
||||
potentially incompatible change. By adhering to these versioning rules, Nenjim can easily and safely handle
|
||||
updates and ensure that the system is always stable and fully functional.</p>
|
||||
<h2>Artifact policy</h2>
|
||||
<ul>
|
||||
<li><code>BLACKLIST</code> is a hard global prohibition.</li>
|
||||
<li><code>DISCOURAGED</code> is a negative hint; a matching release remains selectable.</li>
|
||||
<li><code>RECOMMENDED</code> is a positive hint naming one exact release in the same snapshot.</li>
|
||||
</ul>
|
||||
<p>Overlaps are valid and hard rules win over hints. A future resolver should seek a valid graph containing as
|
||||
many compatible recommendations as possible. The highest valid version is a fallback, not an unconditional
|
||||
rule, so future convergence may move from a higher local version to a lower recommended version.</p>
|
||||
|
||||
<h2 id="8-integration-of-plugins-and-central-registry">8. Integration of Plugins and Central Registry</h2>
|
||||
<p>One of the unique advantages of using Nenjim is that applications can communicate directly with the NenjimHub to
|
||||
find and integrate new plugins. Through a global registry, applications can search for plugins that implement
|
||||
specific interfaces of certain versions, making it easy to extend their functionality. This flexibility allows
|
||||
users to add new features or improvements, such as codecs for a video player, while also being able to see the
|
||||
cost of different plugins.</p>
|
||||
<h2>Strict parsing and immutable model</h2>
|
||||
<p><code>FORMAT_VERSION=1</code> must be the first actual entry. Blank lines and comments beginning with
|
||||
<code>#</code> are accepted, while a hash inside single quotes is data. Quoted values support escaped quotes
|
||||
(<code>\'</code>) and backslashes (<code>\\</code>). Unknown, malformed, duplicate, misplaced, empty, or
|
||||
semantically inconsistent content fails with source name, one-based line number, and a reason. Validation is
|
||||
complete before service state changes. Returned objects and collections are immutable and defensively copied.</p>
|
||||
|
||||
<h2 id="9-integration-with-assetaz-and-economic-activity">9. Integration with AssetAZ and Economic Activity</h2>
|
||||
<p>Nenjim is designed to be an open and free tool for managing dependencies, versioning, and the execution of
|
||||
software packages. The system can be used without any form of payment, and all basic features—such as local
|
||||
injection, dependency analysis, and dynamic classloading—are available without any economic interaction
|
||||
required.</p>
|
||||
<p>However, in cases where the user wants to make their software publicly available to others—for example, by
|
||||
propagating packages to a registry or selling their software—a small economic cost will be associated with
|
||||
these actions.</p>
|
||||
<p>To support this kind of activity, Nenjim uses the digital AssetAZ crypto token. This token is part of the
|
||||
broader AssetAZ platform and enables micropayments in connection with software distribution.</p>
|
||||
<p>By using a dedicated token, a decentralized and transparent settlement mechanism is achieved, while also
|
||||
creating a natural connection to AssetAZ, where the entire economic infrastructure is anchored.</p>
|
||||
<p>It is important to note that this integration does not limit the use of Nenjim in regular, non-commercial
|
||||
contexts, but as soon as you wish to publish and distribute software for commercial purposes—and thus also
|
||||
participate in registration and transaction logic—it requires the use of AssetAZ and its token.</p>
|
||||
<p>In this way, a sustainable ecosystem is created where it is free to use and experiment, but there is a small
|
||||
cost for commercial activity.</p>
|
||||
<h2>Service, ServiceManager, packages, and disk layout</h2>
|
||||
<p>A <code>Service</code> is the public domain API used by applications and other services during ordinary
|
||||
operation. <code>JournalService</code> is therefore filesystem-independent and read-only: it exposes current
|
||||
and historical Journal queries without lifecycle, paths, refresh, parser, or mutation operations.</p>
|
||||
<p>A <code>ServiceManager</code> is the lifecycle and administrative API owned by a composition/lifecycle owner.
|
||||
<code>JournalServiceManager</code> starts and stops the Journal service, reports its normalized data root,
|
||||
performs explicit refresh, and exposes <code>JournalService</code> only after complete successful initial
|
||||
loading. Its reference implementation reads UTF-8 runtime files chronologically and validates directory and
|
||||
filename placement. It creates, overwrites, and deletes nothing.</p>
|
||||
<p><code>JournalServiceImpl</code> keeps exact-text SHA-256 identity, chain state, idempotency, conflicts, forks,
|
||||
and atomic ingestion internal. The public package contains only the two central interfaces; composed models,
|
||||
value types, and exceptions live under <code>.model</code>, <code>.valuetypes</code>, and
|
||||
<code>.exception</code>, while parser, ingestion, chain state, and filesystem loading live under
|
||||
<code>.impl.ref</code> without leaking through public signatures.</p>
|
||||
<pre><code>data/nenjim/journals/
|
||||
└── <artifact-coordinate>/
|
||||
├── <journal-version>.journal
|
||||
└── <journal-version>.journal.example</code></pre>
|
||||
<p>Only <code>.journal</code> files are runtime input. Version-controlled <code>.journal.example</code> files are
|
||||
documentation and are ignored by the service.</p>
|
||||
|
||||
<div class="footer">
|
||||
<p>Translated and rendered from Nenjim.md</p>
|
||||
</div>
|
||||
</div>
|
||||
<h2>Future Nenjim vision</h2>
|
||||
<p>Contexts, dependency-graph resolution, automatic selection/reconciliation, runtime upgrade or downgrade,
|
||||
classloaders, artifact downloads, IPFS retrieval, Maven dependencies, signatures, publishing, remote
|
||||
synchronization, filesystem watching, and UI/CLI management are deliberately outside this implemented
|
||||
Journal foundation.</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+155
-46
@@ -1,5 +1,9 @@
|
||||
#<center><H1>Nenjim<br>Documentation</H1></center>
|
||||
|
||||
> **Dokumentstatus:** Journal-format version 1, den immutable Java-model, den interne tekstparser, det read-only
|
||||
> `JournalService` og den eksplicit refresh-baserede `JournalServiceManager` er implementeret. Afsnit 8–9 beskriver fremtidig vision og er ikke
|
||||
> funktionalitet i den nuværende Journal-implementation.
|
||||
|
||||
**Indholdsfortegnelse:**
|
||||
<!-- TOC -->
|
||||
* [1. Introduktion til Nenjim](#1-introduktion-til-nenjim)
|
||||
@@ -14,68 +18,173 @@
|
||||
<!-- TOC -->
|
||||
|
||||
## 1. Introduktion til Nenjim
|
||||
Nenjim er et innovativt system, der gør det muligt at håndtere forskellige versioner af softwarepakker simultant.
|
||||
Systemet løser udfordringen med afhængigheder og versionering ved at benytte unikke versionsnumre i pakkernes
|
||||
navngivning, hvilket sikrer, at flere versioner af samme softwarepakke kan eksistere side om side uden konflikter.
|
||||
Nenjim skal på sigt kunne håndtere forskellige versioner af softwareartifakter samtidigt. Det nu implementerede
|
||||
fundament er Journal-laget: et strengt tekstformat og en immutable model, som kan fastholde, hvad der var kendt om
|
||||
en artifakt på et bestemt tidspunkt. Dependency-resolution, Contexts, downloading og classloading kommer senere.
|
||||
|
||||
## 2. Versionering og Afhængigheder
|
||||
Nenjim bruger en metode, hvor pakker navngives med versionsnummer direkte i pakkenavnet. Dette håndteres dog i
|
||||
såkaldte journaler, udenfor selve koden, således at Java koden ikke har afhængigheder til Nenjim her.
|
||||
Dette sikrer, at udviklere ikke behøver at bekymre sig om versionskonflikter, da hver afhængighed refererer præcist
|
||||
til den version, den har brug for. Systemet sørger for, at alle nødvendige moduler er tilgængelige og opdaterede
|
||||
i baggrunden.
|
||||
Nenjim beskriver moduler, artifakter, releases, relationer og versionspolitik i Journaler. Almindelig Java-kode skal
|
||||
ikke indbygge Nenjim- eller versionsspecifikke navne for at bruge et API. Et Context beskriver de ønskede valg, og
|
||||
Nenjim resolver senere disse valg og Journalernes metadata til en eksakt artifakt- og dependency-graf.
|
||||
|
||||
### 2.1 Én Journal per artifakt
|
||||
|
||||
En Journal beskriver præcis én artifakt. API, tests og referenceimplementation har derfor hver sin Journal; roller
|
||||
som `API`, `IMPLEMENTATION` og `TEST` gemmes ikke som et `TYPE`-felt. Journalens identitet udledes af metadata:
|
||||
|
||||
```text
|
||||
<GROUP>-<MODULE>-<ARTIFACT>
|
||||
```
|
||||
|
||||
Eksempelkoordinater er `com_r35157_nenjim-hubd-api`, `com_r35157_nenjim-hubd-tests` og
|
||||
`com_r35157_nenjim-hubd-impl_ref`. En komplet release-reference tilføjer en eksakt Semantic Version:
|
||||
|
||||
```text
|
||||
com_r35157_nenjim-hubd-api:1.0.3
|
||||
```
|
||||
|
||||
Der findes intet separat `JOURNAL_ID`.
|
||||
|
||||
### 2.2 Metadata og release-indhold
|
||||
|
||||
`[METADATA]` indeholder coordinate-delene og en obligatorisk single-quoted `DESCRIPTION`. Den valgfrie
|
||||
`INFORMATION_URI` er også single-quoted, skal være en syntaktisk gyldig absolut `URI`, og kan bruge enhver scheme,
|
||||
for eksempel `https:`, `ftp:` eller `ipfs:`. Nenjim åbner eller fortolker ikke URI'en automatisk.
|
||||
|
||||
Hver `[RELEASE major.minor.patch]` indeholder sin egen single-quoted `LICENSE`, `PUBLISHED_AT`, `CONTENT_SIZE` og
|
||||
mindst én `CONTENT_DIGEST`. Format 1 accepterer valideret `sha256` og CIDv1 (`cid1`). Licensen er fri tekst, gemmes
|
||||
uden normalisering og kan være forskellig mellem releases; fremtidig licensfiltrering er ikke implementeret.
|
||||
|
||||
### 2.3 Komplette snapshots og Journal-kæden
|
||||
|
||||
Hver Journal-fil er et komplet immutable snapshot af al aktuel viden om artifakten og dens kendte releases. En
|
||||
revision er aldrig en delta og arver intet implicit. En nyere revision kan ændre metadata, policy, dependencies og
|
||||
selv korrigere eller fjerne tidligere kendt release-information. Et no-op snapshot er også gyldigt.
|
||||
|
||||
`JOURNAL_VERSION` er et strengt stigende UTC-timestamp med millisekundpræcision:
|
||||
|
||||
```text
|
||||
uuuuMMddHHmmssSSS'Z'
|
||||
```
|
||||
|
||||
Genesis-revisionen udelader `PREVIOUS_JOURNAL_DIGEST`. Hver efterfølger refererer til SHA-256 over forgængerens
|
||||
komplette UTF-8-tekst præcis som leveret, inklusive whitespace og linjeskift. Manglende forgængere, forks, flere
|
||||
genesis-revisioner, fremmede artifaktkæder og ikke-stigende versioner afvises.
|
||||
|
||||
`PUBLISHED_AT` er release-tid og er uafhængig af Journalens publiceringstid. Historiske queries vælger et helt
|
||||
snapshot ud fra Journal-kæden; de filtrerer ikke det nyeste snapshot efter `PUBLISHED_AT`.
|
||||
|
||||
### 2.4 Version expressions
|
||||
|
||||
En `SemanticVersion` (`1.0.3`) er én eksakt release. En `VersionExpression` beskriver et sæt acceptable releases:
|
||||
|
||||
* `1.0.3` og `[1.0.3]` er samme eksakte selector.
|
||||
* `[1.0]` betyder hele den stabile `1.0.x`-serie, ikke eksakt `1.0.0`.
|
||||
* `[1.0.3-->1.0.7]` inkluderer begge grænser; `)` ekskluderer øvre grænse.
|
||||
* `[1.0-->1.2]` inkluderer hele `1.2.x`; `[1.0-->1.2)` gør ikke.
|
||||
* `[1.0-->` stopper før `2.0.0`; `(1.0-->` starter ved `1.1.0`.
|
||||
* Komma adskiller en union af expressions.
|
||||
|
||||
Endpoints skal mindst indeholde major og minor. Matematiske operatorer som `>=`, prereleases, build-metadata,
|
||||
tomme elementer, omvendte ranges og implicitte cross-major open ranges afvises.
|
||||
|
||||
### 2.5 Dependencies
|
||||
|
||||
Dependencies hører til en bestemt artifaktrelease og bruger altid en explicit scheme. Format 1 implementerer kun:
|
||||
|
||||
```text
|
||||
DEPENDS_ON=nenjim:com_r35157_nenjim-hubd-api=[1.0-->
|
||||
EXCLUDES=nenjim:com_r35157_nenjim-hubd-api=1.0.5:'Known incompatibility'
|
||||
PREFERRED=nenjim:com_r35157_nenjim-hubd-api=1.0.3:'Built together'
|
||||
```
|
||||
|
||||
`DEPENDS_ON` er basissættet. `EXCLUDES` er en hård relationel begrænsning. `PREFERRED` er kun et hint og skal være
|
||||
en delmængde af `DEPENDS_ON - EXCLUDES`. Flere exclusion/preference-linjer for samme target kombineres som union,
|
||||
men deres individuelle beskrivelser bevares. Ukendte schemes, herunder `maven:`, afvises i format 1.
|
||||
|
||||
### 2.6 Artifact policy
|
||||
|
||||
Den valgfrie `[POLICY]` bevarer individuelle regler og beskrivelser:
|
||||
|
||||
* `BLACKLIST` er en hård global regel: matchende releases må ikke vælges.
|
||||
* `DISCOURAGED` er et negativt hint: releasen kan stadig vælges.
|
||||
* `RECOMMENDED` er et positivt hint til én eksakt release, som findes i samme snapshot.
|
||||
|
||||
Overlap er tilladt, og hårde regler vinder over hints. En fremtidig resolver bør søge en gyldig graf med flest muligt
|
||||
kompatible anbefalinger; højeste versionsnummer er fallback, ikke en ubetinget regel. Det kan senere føre til
|
||||
konvergens fra en højere lokal release til en lavere anbefalet release, men den adfærd er ikke implementeret nu.
|
||||
|
||||
### 2.7 Parser og immutable model
|
||||
|
||||
Parseren kræver `FORMAT_VERSION=1` som første faktiske entry, men tillader blanke linjer og `#`-kommentarer.
|
||||
`#` inde i single quotes er tekst. Quoted værdier understøtter `\'` og `\\`. Ukendte, malformed, duplikerede eller
|
||||
forkert placerede felter/sektioner afvises med source, linjenummer og årsag. Hele dokumentet valideres før servicens
|
||||
state ændres. Alle publicerede modeller og collections er immutable og defensivt kopieret.
|
||||
|
||||
### 2.8 JournalService, JournalServiceManager og disk-layout
|
||||
|
||||
En `Service` er det domæne-API, som applikationer og andre services bruger i daglig drift. `JournalService` er derfor
|
||||
filesystem-uafhængigt og read-only: det kan vise coordinates, kronologiske revisionslister, latest, exact og
|
||||
newest-at-or-before snapshots samt en eksakt release fra et valgt snapshot. Det eksponerer ikke start/stop,
|
||||
filesystem paths, refresh, parser eller mutation.
|
||||
|
||||
En `ServiceManager` ejes af en composition/lifecycle owner og håndterer start, stop, configuration/data location,
|
||||
refresh og adgang til den tilhørende Service. `JournalServiceManager` ejer den normaliserede Journal-root. Ved start
|
||||
læser referenceimplementationen komplette UTF-8-filer kronologisk, validerer placement/navne og gør først sit
|
||||
`JournalService` tilgængeligt, når hele initial loading er lykkedes. Refresh er explicit og idempotent; der er ingen
|
||||
watcher eller background polling, og stop ændrer ikke Journal-filer.
|
||||
|
||||
`JournalServiceImpl` vedligeholder internt kæderne og udfører atomisk ingestion med exact-text SHA-256. Identisk tekst
|
||||
er idempotent; anderledes tekst med samme coordinate og Journal-version er en conflict. Parser, ingestion og mutable
|
||||
chain state er implementation details og findes ikke i public signatures. Public API-pakkerne er opdelt således:
|
||||
|
||||
```text
|
||||
com.r35157.nenjim.service.journal JournalService, JournalServiceManager
|
||||
com.r35157.nenjim.service.journal.model sammensatte immutable domænemodeller
|
||||
com.r35157.nenjim.service.journal.valuetypes små værdidefinerede typer
|
||||
com.r35157.nenjim.service.journal.exception Journal-specifikke exceptions
|
||||
com.r35157.nenjim.service.journal.impl.ref referenceimplementation og interne helpers
|
||||
```
|
||||
|
||||
```text
|
||||
data/nenjim/journals/
|
||||
└── <artifact-coordinate>/
|
||||
├── <journal-version>.journal
|
||||
└── <journal-version>.journal.example
|
||||
```
|
||||
|
||||
Kun `.journal` er runtime-input. `.journal.example` dokumenterer formatet og indlæses ikke. Roden skal allerede
|
||||
eksistere og være læsbar; service-manageren opretter, overskriver eller sletter ikke Journal-filer.
|
||||
|
||||
## 3. Opdateringer og Sikkerhed
|
||||
Nenjim holder automatisk øje med opdateringer gennem det der kaldes journaler. Hvis der opdages en sikkerhedsbrist
|
||||
i en bestemt version, opdaterer udviklerne journalen, og alle NenjimHubs vil automatisk hente den nye sikrede version,
|
||||
eller nedgradere til en tidligere version indtil en opdatering er klar. På denne måde spredes opdateringer hurtigt
|
||||
og effektivt i hele netværket.
|
||||
Det implementerede Journal-lag kan repræsentere nye sikkerhedsoplysninger som en nyere immutable revision, for
|
||||
eksempel via `BLACKLIST` eller en ændret `RECOMMENDED`. Automatisk synchronization, selection, reconciliation og
|
||||
upgrade/downgrade er fremtidigt arbejde.
|
||||
|
||||
## 4. Betalingssystem og Integration med AssetAZ
|
||||
For at understøtte betaling for softwarepakker har Nenjim en integration med AssetAZ. Det betyder, at udviklere kan
|
||||
vælge at modtage betaling for deres pakker enten som engangsbeløb, abonnement eller per brug. Det hele håndteres
|
||||
via kryptovaluta.
|
||||
Betaling og AssetAZ-integration er langsigtet vision og er ikke en del af Journal-formatet eller denne implementation.
|
||||
|
||||
## 5. Backing Store og Fleksibilitet
|
||||
Nenjim giver fuld fleksibilitet i forhold til, hvor data lagres. Det kan være via på IPFS, i en lokal mappe, eller
|
||||
endda i en database. Systemet er designet til at være så fleksibelt, at udviklere kan vælge den løsning, der
|
||||
passer bedst til deres behov.
|
||||
Den nuværende `JournalServiceManagerImpl` læser kun det dokumenterede lokale disk-layout. Artifact downloading, IPFS retrieval,
|
||||
remote Journals og andre backing stores er ikke implementeret. En `ipfs:`-værdi i `INFORMATION_URI` er metadata,
|
||||
ikke en retrieval-instruks.
|
||||
|
||||
## 6. Standardiseret og Automatiseret Pakkehåndtering
|
||||
En af de store fordele ved Nenjim er, at brugere ikke længere behøver at søge manuelt efter afhængigheder på
|
||||
Internettet. Nenjim benytter en standardiseret metode til at finde og downloade pakker automatisk f.eks. via IPFS.
|
||||
Det betyder, at alle pakker er let tilgængelige og kan hentes på en ensartet måde, hvilket sparer tid og sikrer
|
||||
en mere strømlinet oplevelse for både udviklere og brugere.
|
||||
Journalen modellerer dependency-krav og indholdsidentiteter, men downloader ikke bytes og verificerer ikke downloadet
|
||||
indhold. Dependency-graph resolution, automatisk release-selection og Maven-resolution er fremtidigt arbejde.
|
||||
|
||||
## 7. Betydningen af Semantisk Versionering
|
||||
For at Nenjim kan fungere optimalt, er det essentielt, at alle pakker følger principperne for semantisk
|
||||
versionering. Det betyder, at hver version af en pakke tydeligt angiver, om der er tale om en mindre opdatering,
|
||||
en fejlrettelse eller en større, potentielt inkompatibel ændring. Ved at overholde disse versioneringsregler
|
||||
kan Nenjim nemt og sikkert håndtere opdateringer og sikre, at systemet altid er stabilt og velfungerende.
|
||||
Format 1 accepterer kun stabile eksakte releases med alle tre numeriske komponenter. Det forudsætter ikke, at en
|
||||
resolver altid vælger den numerisk højeste version: hard constraints afgrænser de gyldige valg, og policy/dependency
|
||||
hints kan på sigt foretrække en lavere kompatibel release.
|
||||
|
||||
## 8. Integration af Plugins og Centralt Registry
|
||||
En af de unikke fordele ved at benytte Nenjim er, at applikationer kan kommunikere direkte med NenjimHub'en
|
||||
for at finde og integrere nye plugins. Gennem et globalt registry kan applikationer søge efter plugins,
|
||||
der implementerer bestemte interfaces af specifikke versioner, og dermed let udvide deres funktionalitet.
|
||||
Denne fleksibilitet gør det muligt for brugere at tilføje nye features eller forbedringer, som for eksempel
|
||||
codecs til en videoafspiller og samtidig se, hvad de forskellige plugins koster.
|
||||
Plugin discovery, et centralt registry, Journal publishing, remote synchronization og signature-validering er
|
||||
fremtidig vision. De er ikke implementeret af Journal-modellen, servicen eller service-manageren.
|
||||
|
||||
## 9. Integration med AssetAZ og økonomisk aktivitet
|
||||
Nenjim er designet til at være et åbent og frit værktøj til håndtering af afhængigheder, versionering og afvikling af
|
||||
softwarepakker. Systemet kan bruges uden nogen form for betaling, og alle grundlæggende funktioner – som f.eks.
|
||||
lokal injektion, analyse af afhængigheder og dynamisk classloading – er tilgængelige uden krav om økonomisk interaktion.
|
||||
Men i de tilfælde, hvor brugeren ønsker at gøre sin software offentligt tilgængelig for andre – f.eks. ved at
|
||||
propagere pakker til et registry eller sælge sin software – vil der være knyttet en lille økonomisk omkostning til
|
||||
disse handlinger.
|
||||
For at understøtte denne form for aktivitet, anvender Nenjim den digitale AssetAZ crypto token. Denne token er
|
||||
en del af den bredere AssetAZ-platform og giver mulighed for mikrobetalinger i forbindelse med softwaredistribution.
|
||||
Ved at benytte en dedikeret token opnås en decentral og gennemsigtig afregningsmekanisme, samtidig med at det skaber
|
||||
en naturlig kobling til AssetAZ, hvor hele den økonomiske infrastruktur er forankret.
|
||||
Det er vigtigt at bemærke, at denne integration ikke begrænser brugen af Nenjim i almindelige, ikke-kommercielle
|
||||
sammenhænge, men i det øjeblik man ønsker at publicere og distribuere software med økonomisk formål – og dermed
|
||||
også deltage i registrering og transaktionslogik – kræver det at AssetAZ og dens token anvendes.
|
||||
På den måde skabes et bæredygtigt økosystem, hvor det er gratis at bruge og eksperimentere, men koster et lille beløb
|
||||
at gøre noget kommercielt.
|
||||
Økonomi, publicering og distribution er fremtidig vision. Format 1 beskriver alene artifaktviden; det indeholder
|
||||
ingen betalings-, salgs- eller transaktionsadfærd.
|
||||
|
||||
## 10. Installation af en NenjimHub
|
||||
For at NenjimHub'en kan køre godt, skal den leve i et samspil med nogle andre komponenter. Dette kan sættes op på
|
||||
|
||||
+40
-16
@@ -1,18 +1,42 @@
|
||||
# Tekniske termer
|
||||
| Term | Beskrivelse |
|
||||
|--------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| Modul | Et modul er den mindste logiske enhed i r35157-økosystemet. Hvert modul indeholder ét samlet ansvarsområde. Et modul består af én API-artifakt, nul eller flere Implementations-artifakter, og typisk ét Test-artifakt. Moduler kan bruges som afhængigheder af andre moduler. |
|
||||
| Artifakt | Et artifakt er et build-resultat genereret for et modul (fx en JAR-fil, Docker-image eller ZIP-pakke). Artifakter er immutable, signeret og versioneres efter Semantic Versioning. Under udvikling publiceres artifakter typisk til et Maven repository; i drift og distribution håndteres de af Nenjim. |
|
||||
| GroupId | Et artifakt er tilknyttet en gruppe, f.eks 'com.r35157.libs'. I maven er bruges '.' som skilletegn, mens der bruges '_' til repositories. |
|
||||
| ModuleId | Et artifakt er en del af et modul
|
||||
| ArtifaktId | Et artifakt id er unikt og består af <moduleId>-<artifaktType>(-<implId>)
|
||||
| API-artifakt | Et build output som definerer grænseflader og dokumentation for et modul. |
|
||||
| Implementations-artifakt | Et build output som lave en, evt. blandt flere, implementationer af de grænseflader som er defineret i API-artifaktet. |
|
||||
| Test-artifakt | Et build output som er en generel test implementation, som kan teste forskellige Implementations-artifakter for at sikre at det overholder de grænseflader og opførsel som er defineret i API-artifaktet. |
|
||||
|
||||
# Domæne specifikke termer
|
||||
| Term | Beskrivelse |
|
||||
|----------------|-------------------------------------------------------------------------------------------------------|
|
||||
| Position Value | Dette er den samlede nutidsværdi af in position (nuværende pris × antal) |
|
||||
| Base Value | Dette er den pris det har kostet at købe positionen (gennemsnits købspris × antal) |
|
||||
| Margin Value | Dette er den pris man faktisk har skulle betale for at købe en gearet position (base value / gearing) |
|
||||
> **Dokumentstatus:** Journal-format version 1 og de tilhørende Java-typer, den interne parser, query-service og lifecycle-manager er
|
||||
> implementeret. Resolver-, Context-, classloader-, retrieval- og publishing-termer beskriver kun fremtidig vision,
|
||||
> når det er angivet.
|
||||
|
||||
| Term | Beskrivelse |
|
||||
|------|-------------|
|
||||
| Service | Det offentlige domæne-API, som applikationer og andre services bruger i daglig drift. Det eksponerer almindelige domæneoperationer, men ikke lifecycle, configuration, data-source administration, refresh, parser eller mutation af intern viden. |
|
||||
| ServiceManager | Det offentlige lifecycle- og administrations-API, som en composition/lifecycle owner bruger til start, stop, configuration/data location, refresh og adgang til den tilhørende Service efter vellykket initialisering. Det er ikke det query-API, som almindelige consumers bruger. |
|
||||
| Journal | Et immutable, komplet snapshot af al aktuel viden om præcis én artifakt og dens kendte releases. Journalen er aldrig en delta. |
|
||||
| ArtifactCoordinate | Journal-kædens stabile identitet, udledt som `<GROUP>-<MODULE>-<ARTIFACT>`, f.eks. `com_r35157_nenjim-hubd-api`. Der findes intet separat `JournalId`. |
|
||||
| JournalVersion | Journal-snapshottets publiceringstid som UTC med millisekundpræcision: `uuuuMMddHHmmssSSS'Z'`. Den er forskellig fra en releases `PUBLISHED_AT`. |
|
||||
| Journal-kæde | Én genesis og en lineær række immutable snapshots for samme coordinate. Hver efterfølger refererer med SHA-256 til forgængerens eksakte UTF-8-tekst. |
|
||||
| Historisk worldview | Hele det snapshot, som var nyest på eller før en valgt `JournalVersion`; det beregnes ikke ved at filtrere et nyere snapshot efter release-tid. |
|
||||
| Artifact release | En eksakt stabil `major.minor.patch`-version af én artifakt med per-release licens, publication time, content digests, content size og dependencies. |
|
||||
| DESCRIPTION | Obligatorisk single-quoted artifactbeskrivelse i `[METADATA]`; decoded tekst kan ændres i en senere Journal-revision. |
|
||||
| INFORMATION_URI | Valgfri single-quoted, syntaktisk gyldig absolut URI. Alle schemes er tilladt; indholdet åbnes eller fortolkes ikke automatisk. |
|
||||
| LICENSE | Obligatorisk single-quoted fri tekst for hver release. Den normaliseres eller juridisk fortolkes ikke; fremtidig filtrering er ikke implementeret. |
|
||||
| ContentDigest | Valideret indholdsidentitet. Format 1 understøtter `sha256` og CIDv1 (`cid1`), men downloader eller verificerer ikke artifact-bytes. |
|
||||
| SemanticVersion | Én eksakt stabil artifaktrelease med major, minor og patch. Prerelease/build metadata understøttes ikke i format 1. |
|
||||
| VersionExpression | Et normaliseret sæt acceptable releases: exact, minor-serie eller inclusive/exclusive/open range. Mathematical comparison syntax understøttes ikke. |
|
||||
| DependencyTarget | Et explicit scheme og et scheme-specifikt target. Format 1 implementerer kun `nenjim:<artifact-coordinate>`; Maven-resolution er fremtidig. |
|
||||
| DEPENDS_ON | Den hårde basisramme af acceptable releases for én dependency-relation. |
|
||||
| EXCLUDES | En hård relationel regel, som fjerner versioner fra én bestemt `DEPENDS_ON`. Individuelle beskrivelser bevares. |
|
||||
| PREFERRED | Et hint til en delmængde af `DEPENDS_ON - EXCLUDES`; det kan aldrig gøre en ellers ugyldig version gyldig. |
|
||||
| BLACKLIST | En hård artifact-policy: matchende releases må ikke vælges. |
|
||||
| DISCOURAGED | Et negativt artifact-policy-hint: matchende releases bør undgås, men er fortsat selectable. |
|
||||
| RECOMMENDED | Et positivt hint til én eksakt release, der findes i samme snapshot. Højeste version er kun en fremtidig fallback, ikke en ubetinget selection-regel. |
|
||||
| JournalService | Filesystem-uafhængigt, read-only Service-API for current og historical Journal-queries. Det eksponerer ingen lifecycle-, path-, refresh-, parser- eller mutationsoperationer. |
|
||||
| JournalServiceManager | Lifecycle- og administrations-API for JournalService. Det ejer den normaliserede data root, udfører komplet initial loading ved start, stopper servicen, udfører explicit refresh og udleverer JournalService efter vellykket initialisering. |
|
||||
| Journal referenceimplementation | `JournalServiceImpl`, `JournalServiceManagerImpl`, `JournalTextParser`, intern ingestion, chain state og filesystem-loading under `com.r35157.nenjim.service.journal.impl.ref`; implementation details lækker ikke gennem public signatures. |
|
||||
| Context | Fremtidig beskrivelse af ønskede/resolverede artifactvalg; ikke implementeret af denne Journal-change. |
|
||||
| Resolver | Fremtidig komponent for dependency graph og release-selection; Journal-laget repræsenterer reglerne, men vælger ikke en graf. |
|
||||
|
||||
# Domænespecifikke termer
|
||||
|
||||
| Term | Beskrivelse |
|
||||
|------|-------------|
|
||||
| Position Value | Den samlede nutidsværdi af en position (nuværende pris × antal). |
|
||||
| Base Value | Den pris det har kostet at købe positionen (gennemsnitlig købspris × antal). |
|
||||
| Margin Value | Den pris man faktisk har betalt for en gearet position (base value / gearing). |
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-08-19
|
||||
@@ -0,0 +1,82 @@
|
||||
## Context
|
||||
|
||||
See `proposal.md` for motivation and the two delta specs for observable behavior. Commit `7eeedcc` contains the format, immutable domain behavior, parser, chain validation, queries, examples, and documentation, but as an intermediate API under `com.r35157.nenjim.journal`: a public mutable `JournalManager` owns chain state and a concrete filesystem `JournalService` owns refresh. The updated issue supersedes that package and responsibility split. Human-written Java remains in `.tjava`; Detag runs through the normal Gradle build.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- Make `JournalService` the filesystem-independent read-only API and `JournalServiceManager` the lifecycle/data-root/refresh API.
|
||||
- Keep exactly those two interfaces directly in `com.r35157.nenjim.service.journal`, with composed models, value types, exceptions, and reference implementation in their prescribed subpackages.
|
||||
- Preserve the already implemented format, immutable construction invariants, chain semantics, queries, exact-text identity, CID canonicality, examples, and diagnostics.
|
||||
- Keep parser, mutable ingestion, chain storage, and filesystem traversal out of public signatures.
|
||||
- Ensure a failed initial start never exposes a partially loaded service and a failed individual ingestion never mutates previously accepted state.
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- No compatibility adapters for `com.r35157.nenjim.journal`, no public `JournalManager`, and no deprecated or forwarding aliases.
|
||||
- No Context, resolver, selection, classloader, retrieval, publishing, synchronization, watching, signing, UI/CLI, URI interpretation, or license filtering.
|
||||
- No wiring into NenjimHub startup and no changes to CIS or any unrelated service.
|
||||
- No automated test sources or test-configuration changes; strict OpenSpec/build checks and temporary runtime probes provide verification.
|
||||
- No transactional refresh across a whole directory tree. Each ingestion is atomic; a refresh can have accepted earlier valid files before a later file fails.
|
||||
|
||||
## Decisions
|
||||
|
||||
### Public packages follow semantic API roles
|
||||
|
||||
The package layout is:
|
||||
|
||||
- `com.r35157.nenjim.service.journal`: only `JournalService` and `JournalServiceManager`.
|
||||
- `.model`: `Journal`, `JournalMetadata`, `JournalPolicy`, `ArtifactRelease`, `ArtifactDependency`, and `PolicyRule`.
|
||||
- `.valuetypes`: `ArtifactCoordinate`, `JournalVersion`, `ContentDigest`, `DependencyTarget`, `VersionExpression`, and `VersionExpressionSet`.
|
||||
- `.exception`: `InvalidJournalException`.
|
||||
- `.impl.ref`: `JournalServiceImpl`, `JournalServiceManagerImpl`, `JournalTextParser`, and internal helpers.
|
||||
|
||||
Composed records belong in `model` even when they are records; only small values fully defined by their value belong in `valuetypes`. This explicit split makes the logical module extractable later and prevents the current repository location from becoming accidental API ownership. The old package is deleted rather than wrapped because the issue explicitly replaces the intermediate API.
|
||||
|
||||
### The service exposes queries while ingestion stays package-private
|
||||
|
||||
`JournalService` contains only coordinate, revision, latest, exact, as-of, and selected-release queries. `JournalServiceImpl` implements those operations over synchronized in-memory chain state. Its constructor, non-mutating parse helper, mutable text-ingestion method, stored exact text, digest-owner index, and stored-revision type remain package-private or private implementation details.
|
||||
|
||||
Internal ingestion parses and validates the complete string before acquiring the state lock, calculates SHA-256 over the exact UTF-8 text, and then checks idempotency, conflicts, genesis/head identity, predecessor ownership, and strictly increasing Journal time before replacing one immutable chain snapshot. Parse or chain failure therefore leaves all existing state unchanged. Query results remain immutable copies or optionals.
|
||||
|
||||
The earlier public parser contract is removed. `JournalTextParser` is a package-private reference helper under `.impl.ref`; it still owns textual syntax, quoting, ordering, comments, duplicate detection, source/line diagnostics, and conversion. Public models and value types continue to protect their own construction invariants. In particular, `JournalPolicy` requires every recommended rule to contain exactly one exact stable version, and `Journal` requires that version to exist in its own release map. The parser deliberately repeats both checks so invalid text retains precise source- and line-level diagnostics even though direct model construction is protected independently.
|
||||
|
||||
### The service manager owns lifecycle and filesystem access
|
||||
|
||||
`JournalServiceManagerImpl` normalizes and stores the configured root. On `start`, it creates a fresh candidate `JournalServiceImpl`, validates the root, reads every runtime file in artifact-directory and Journal-version order, validates placement with the internal parse helper, and ingests each complete text. Only after all initial files succeed is the candidate published through `journalService()` and the manager considered started. A failed start discards the candidate, so no partially loaded service is exposed.
|
||||
|
||||
`journalService()` and `refresh()` require a successfully started manager. `stop()` ends the manager lifecycle and stops exposing its owned service without touching Journal files. A service reference already handed to a caller remains a safe read-only snapshot holder; the manager cannot revoke an ordinary Java reference, but it will not return that instance after stop and a later start builds a fresh instance.
|
||||
|
||||
Refresh reuses the active service and repeats chronological loading. Exact-text idempotency makes unchanged files harmless and newly appended successors become visible immediately. File-by-file refresh is retained because the issue requires atomic ingestion, not an all-directory transaction.
|
||||
|
||||
### Filesystem placement is validated before mutation
|
||||
|
||||
The manager implementation accepts only direct artifact-coordinate directories and direct `.journal` runtime files, ignores `.journal.example`, rejects unexpected entries, and never creates or writes the root. It parses each file without mutation to compare metadata coordinate and `JOURNAL_VERSION` with directory and filename, then invokes internal ingestion. Parsing twice is deliberate: placement must fail before mutation, while the ingestion boundary must independently accept and validate a complete text rather than trusting filesystem preparation.
|
||||
|
||||
### Existing format and model algorithms migrate unchanged in meaning
|
||||
|
||||
The line-oriented parser, canonical half-open version intervals, immutable collection copies, optional URI handling, free-text license handling, dependency/policy validation, exact timestamp rules, and CIDv1 canonical Base32/minimal varint checks keep their behavior while imports and packages move. This minimizes semantic regression during an API-only architectural migration.
|
||||
|
||||
### Documentation uses Service terminology consistently
|
||||
|
||||
`docs/Terminologi.md` defines general `Service` and `ServiceManager` concepts before the Journal-specific entries. `docs/Nenjim.md` and `docs/Nenjim-public.html` describe the public query service, lifecycle manager, package boundary, start/refresh behavior, and future-work boundary without carrying forward the public `JournalManager` or filesystem-facing `JournalService` model.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [Moving every public type is a source- and binary-incompatible change] → The intermediate API is explicitly superseded; delete it completely and update all repository references in one change without aliases.
|
||||
- [Initial loading may ingest several files before a later one fails] → It happens in an unexposed candidate service that is discarded on start failure.
|
||||
- [A refresh is not atomic across the complete tree] → Each ingestion remains atomic and files are immutable; the failing path is reported and can be corrected before another refresh.
|
||||
- [A previously returned service reference cannot be revoked on stop] → It exposes only immutable read queries and no lifecycle, refresh, filesystem, or mutation capability.
|
||||
- [Strict parsing can reject hand-written content] → Diagnostics retain logical source, one-based line, and a concrete reason; version-controlled examples document accepted syntax.
|
||||
- [Synchronized service methods serialize ingestion and queries] → Journal sets are expected to be small and refresh is explicit; correctness is preferred until profiling demonstrates contention.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. Reconcile proposal, specs, design, and tasks with the updated authoritative issue.
|
||||
2. Move public composed models, value types, and the exception into their required packages; add the two central interfaces and remove the public parser/manager surface.
|
||||
3. Refactor the old chain manager into `JournalServiceImpl` and the old filesystem façade into lifecycle-aware `JournalServiceManagerImpl`; move `JournalTextParser` and all helpers behind the implementation boundary.
|
||||
4. Update imports and all three required documentation files, then verify the old package and public `JournalManager` are absent.
|
||||
5. Strictly validate the active OpenSpec change, compile through Detag, parse every example, probe lifecycle/refresh/atomicity/idempotency/conflicts/forks/history, and inspect the final diff without syncing, archiving, committing, or pushing.
|
||||
|
||||
Rollback before publication is the normal source-control reversal of these uncommitted changes. There is no runtime data migration or persisted-state mutation in this change.
|
||||
@@ -0,0 +1,34 @@
|
||||
## Why
|
||||
|
||||
Nenjim needs a stable, executable Journal foundation before Context and classloader work can continue. The Journal API must also follow Nenjim's service architecture so ordinary consumers receive a filesystem-independent read-only service while lifecycle and data-source administration remain with a separate service manager.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Add an immutable public Journal domain model for one artifact per Journal, exact releases, content identity, dependencies, policy, and normalized version expressions.
|
||||
- Add strict format-version-1 parsing and whole-document validation, including quoted metadata/license text, timestamps, digests, dependency constraints, policy, and precise source/line diagnostics.
|
||||
- Add a filesystem-independent, read-only `JournalService` query interface and a reference `JournalServiceImpl` that maintains independent immutable revision chains behind an internal ingestion operation.
|
||||
- Add a lifecycle and administrative `JournalServiceManager` interface and filesystem-facing `JournalServiceManagerImpl` that owns the data root, performs initial chronological loading and explicit refresh, and exposes the service only after successful start.
|
||||
- Separate the two central service interfaces, composed models, value types, exceptions, and reference implementation into the mandated public and implementation packages.
|
||||
- **BREAKING**: Remove the intermediate `com.r35157.nenjim.journal` API, its public `JournalManager`, and the older `hubd.journal`, `hubd.module`, `JournalManagerImpl`, and `JournalId` skeletons without compatibility aliases.
|
||||
- Move the three example Journals into artifact-coordinate revision directories and rewrite them as valid format-version-1 snapshots.
|
||||
- Rewrite Nenjim documentation and terminology around artifact-scoped immutable snapshots, chain history, dependency/policy semantics, content/license metadata, and the general `Service`/`ServiceManager` responsibility split.
|
||||
- Keep Contexts, resolution, release selection, classloading, retrieval, signatures, publishing, synchronization, watching, UI/CLI, URI interpretation, and license filtering outside this change.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `nenjim-journal-format`: Defines the immutable Journal model, format-version-1 grammar, version-expression semantics, and complete structural and semantic validation contract.
|
||||
- `nenjim-journal-management`: Defines the read-only query service, lifecycle manager, internal atomic Journal-chain ingestion, package boundary, and filesystem refresh/layout behavior.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
None.
|
||||
|
||||
## Impact
|
||||
|
||||
- The only types directly under `com.r35157.nenjim.service.journal` are `JournalService` and `JournalServiceManager`; public models, value types, and exceptions use their specified subpackages.
|
||||
- Reference parsing, mutable ingestion, chain state, and filesystem loading live under `com.r35157.nenjim.service.journal.impl.ref` and do not leak through public signatures.
|
||||
- Existing obsolete Journal/module source files and references are removed or rewritten.
|
||||
- Journal examples under `data/nenjim/journals` and `docs/Nenjim.md`, `docs/Terminologi.md`, and `docs/Nenjim-public.html` change substantially.
|
||||
- No new external dependency or runtime integration with Context/classloading is introduced.
|
||||
+212
@@ -0,0 +1,212 @@
|
||||
## Purpose
|
||||
|
||||
Defines the observable format-version-1 contract and immutable domain meaning of a complete, artifact-scoped Nenjim Journal snapshot.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Artifact-scoped complete snapshots
|
||||
The Journal format SHALL describe exactly one artifact identified by the coordinate `<GROUP>-<MODULE>-<ARTIFACT>`, SHALL contain no `JOURNAL_ID` or artifact `TYPE`, and SHALL represent a complete immutable worldview rather than a delta from its predecessor.
|
||||
|
||||
#### Scenario: Valid artifact identity
|
||||
- **WHEN** metadata contains non-empty `GROUP=com_r35157_nenjim`, `MODULE=hubd`, and `ARTIFACT=api`
|
||||
- **THEN** the parsed coordinate is `com_r35157_nenjim-hubd-api`
|
||||
|
||||
#### Scenario: Obsolete identity fields
|
||||
- **WHEN** a Journal contains `JOURNAL_ID` or `TYPE`
|
||||
- **THEN** parsing fails as an unknown or misplaced field without silently ignoring it
|
||||
|
||||
#### Scenario: No-op semantic revision
|
||||
- **WHEN** a later valid snapshot differs from its predecessor only by `JOURNAL_VERSION` and `PREVIOUS_JOURNAL_DIGEST`
|
||||
- **THEN** the snapshot remains valid because revisions are complete worldviews and need not change their semantic content
|
||||
|
||||
### Requirement: Strict top-level structure and comments
|
||||
The parser SHALL require the logical order `FORMAT_VERSION=1`, `JOURNAL_VERSION`, optional `PREVIOUS_JOURNAL_DIGEST`, `[METADATA]`, optional `[POLICY]`, and one or more `[RELEASE <version>]` sections. Blank lines and full-line or trailing `#` comments SHALL be accepted, except that a `#` inside a quoted value is data.
|
||||
|
||||
#### Scenario: Comments before format entry
|
||||
- **WHEN** blank lines and full-line comments precede `FORMAT_VERSION=1`
|
||||
- **THEN** the format entry is treated as the first actual configuration entry and parsing proceeds
|
||||
|
||||
#### Scenario: Hash inside quoted text
|
||||
- **WHEN** `DESCRIPTION='Project #1' # trailing comment` is parsed
|
||||
- **THEN** the decoded description is `Project #1` and the trailing comment is discarded
|
||||
|
||||
#### Scenario: Unsupported format
|
||||
- **WHEN** the first actual entry is `FORMAT_VERSION=2`
|
||||
- **THEN** parsing fails explicitly because the format version is unsupported
|
||||
|
||||
#### Scenario: Misordered or duplicate structure
|
||||
- **WHEN** a required entry or section is missing, duplicated, malformed, unknown, or placed outside its defined order
|
||||
- **THEN** parsing fails and no unknown content is ignored
|
||||
|
||||
### Requirement: Journal and publication timestamps
|
||||
`JOURNAL_VERSION` and `PUBLISHED_AT` SHALL each use a strict UTC `uuuuMMddHHmmssSSS'Z'` timestamp with millisecond precision and SHALL retain distinct revision-time and release-time meanings.
|
||||
|
||||
#### Scenario: Valid timestamp
|
||||
- **WHEN** `20260819121530842Z` is supplied as a Journal or publication timestamp
|
||||
- **THEN** it is represented as the corresponding immutable UTC instant/value
|
||||
|
||||
#### Scenario: Invalid timestamp
|
||||
- **WHEN** a timestamp has missing milliseconds, a non-UTC suffix, an impossible calendar value, or trailing characters
|
||||
- **THEN** parsing fails
|
||||
|
||||
### Requirement: Quoted metadata values
|
||||
`[METADATA]` SHALL contain exactly one non-empty `GROUP`, `MODULE`, `ARTIFACT`, and single-quoted `DESCRIPTION`, and MAY contain at most one single-quoted `INFORMATION_URI`. The parser SHALL decode `\'` and `\\`, reject other malformed escaping and empty quoted values, and expose information URI as `Optional<URI>` only when the decoded value is a syntactically valid non-empty absolute URI, without restricting its scheme.
|
||||
|
||||
#### Scenario: Valid quoted metadata
|
||||
- **WHEN** metadata contains `DESCRIPTION='Nenjim\'s reference hub'` and `INFORMATION_URI='ftp://ftp.domain.com/components/123.txt'`
|
||||
- **THEN** the model exposes description `Nenjim's reference hub` and the exact absolute FTP URI
|
||||
|
||||
#### Scenario: Optional information URI absent
|
||||
- **WHEN** valid metadata omits `INFORMATION_URI`
|
||||
- **THEN** the model exposes an empty optional URI
|
||||
|
||||
#### Scenario: Scheme-independent URI
|
||||
- **WHEN** a syntactically valid absolute `https:`, `ftp:`, or `ipfs:` URI is supplied
|
||||
- **THEN** parsing accepts it without fetching, opening, or scheme-specific interpretation
|
||||
|
||||
#### Scenario: Invalid metadata text or URI
|
||||
- **WHEN** a required quoted value is unquoted, empty, unterminated, badly escaped, or the information URI is relative or syntactically invalid
|
||||
- **THEN** parsing fails at that field
|
||||
|
||||
### Requirement: Exact artifact releases and content
|
||||
Every release section SHALL identify a unique exact `major.minor.patch` stable semantic version and SHALL contain exactly one non-empty quoted `LICENSE`, exactly one valid `PUBLISHED_AT`, one or more supported unique `CONTENT_DIGEST` values, and exactly one non-negative long `CONTENT_SIZE`.
|
||||
|
||||
#### Scenario: Valid release content
|
||||
- **WHEN** release `1.0.3` contains decoded license text, a valid publication timestamp, a SHA-256 digest, an optional CIDv1 digest, and a non-negative content size
|
||||
- **THEN** the immutable release model exposes all values without normalizing or interpreting the license
|
||||
|
||||
#### Scenario: Invalid release version
|
||||
- **WHEN** a release heading uses `1`, `1.0`, `1.0.0-beta`, or `1.0.0+build.42`
|
||||
- **THEN** parsing fails rather than normalizing it to another exact release
|
||||
|
||||
#### Scenario: Duplicate release
|
||||
- **WHEN** two release sections identify the same exact version
|
||||
- **THEN** parsing fails
|
||||
|
||||
#### Scenario: Invalid license
|
||||
- **WHEN** a release omits `LICENSE`, repeats it, or supplies an unquoted, malformed, or empty value
|
||||
- **THEN** parsing fails
|
||||
|
||||
#### Scenario: Digest validation
|
||||
- **WHEN** content digests use valid `sha256:<64 lowercase hexadecimal characters>` and/or `cid1:<CIDv1 base32 text>` values whose payload is the exact canonical unpadded lowercase Base32 encoding of a valid CIDv1 envelope, with at most one digest of each scheme
|
||||
- **THEN** all supplied content identities remain inspectable in the release model
|
||||
|
||||
#### Scenario: Canonical CIDv1 envelope
|
||||
- **WHEN** a CIDv1 content digest is supplied
|
||||
- **THEN** its version, multicodec, multihash code, and digest-length fields use their shortest unsigned varint encoding within the supported `long` range, and the declared digest length matches the payload
|
||||
|
||||
#### Scenario: Non-canonical CIDv1
|
||||
- **WHEN** a CIDv1 spelling has non-zero unused Base32 padding bits, a non-minimal varint, or a varint that overflows the supported `long` range in any envelope field
|
||||
- **THEN** parsing fails even if a permissive decoder could produce the same CID bytes or a truncated numeric value
|
||||
|
||||
#### Scenario: Invalid content identity
|
||||
- **WHEN** no content digest exists, a digest scheme is unknown, a digest syntax is invalid, a scheme repeats, or content size is negative or outside the long range
|
||||
- **THEN** parsing fails
|
||||
|
||||
### Requirement: Normalized version expressions
|
||||
The model SHALL distinguish exact artifact versions from partial version boundaries, normalize semantically equivalent selector syntax, support membership and set containment, and reject unsupported or empty expressions.
|
||||
|
||||
#### Scenario: Equivalent exact forms
|
||||
- **WHEN** `1.0.3` and `[1.0.3]` are parsed
|
||||
- **THEN** both produce the same exact version-expression representation
|
||||
|
||||
#### Scenario: Minor series
|
||||
- **WHEN** `[1.0]` is parsed
|
||||
- **THEN** it contains every stable `1.0.x` release from `1.0.0` through the release immediately before `1.1.0` and is not normalized to exact `1.0.0`
|
||||
|
||||
#### Scenario: Inclusive and exclusive ranges
|
||||
- **WHEN** `[1.0.3-->1.0.7]` and `[1.0.3-->1.0.7)` are parsed
|
||||
- **THEN** the first contains both endpoints and the second excludes `1.0.7`
|
||||
|
||||
#### Scenario: Partial range boundaries
|
||||
- **WHEN** `[1.0-->1.2]` or `[1.0-->1.2)` is parsed
|
||||
- **THEN** the inclusive form contains the entire `1.2.x` series and the exclusive form excludes the entire `1.2.x` series
|
||||
|
||||
#### Scenario: Explicit cross-major range
|
||||
- **WHEN** `[1.0-->3.0)` is parsed
|
||||
- **THEN** it contains stable versions in major lines 1 and 2 but none in major line 3
|
||||
|
||||
#### Scenario: Open upper range
|
||||
- **WHEN** `[1.0-->`, `(1.0-->`, or `[1.0.3-->` is parsed
|
||||
- **THEN** its implicit exclusive upper boundary is `2.0.0`, while its lower boundary respectively starts at `1.0.0`, `1.1.0`, or `1.0.3`
|
||||
|
||||
#### Scenario: Expression union
|
||||
- **WHEN** `[1.0.0-->1.0.3], [1.0.7], 1.1.1` is parsed for a repeatable/list-valued rule
|
||||
- **THEN** the model represents the normalized union and accepts whitespace around commas and `-->`
|
||||
|
||||
#### Scenario: Invalid expression
|
||||
- **WHEN** an expression uses a major-only boundary, a bare incomplete exact version, prerelease/build syntax, mathematical comparison syntax, whitespace inside a numeric version, an empty list element, reversed boundaries, or an empty range
|
||||
- **THEN** parsing fails
|
||||
|
||||
### Requirement: Nenjim dependencies
|
||||
Each artifact release SHALL model each `nenjim:<artifact-coordinate>` dependency target with exactly one `DEPENDS_ON` base expression set, zero or more described `EXCLUDES` entries, and zero or more described `PREFERRED` entries. Unknown schemes SHALL fail explicitly, exclusions and preferences SHALL require a corresponding base dependency, and the complete preferred set SHALL be contained by `DEPENDS_ON - EXCLUDES`.
|
||||
|
||||
#### Scenario: Valid dependency relationship
|
||||
- **WHEN** a release depends on `nenjim:com_r35157_nenjim-hubd-api=[1.0-->`, excludes `1.0.4` with a description, and prefers `1.0.3`
|
||||
- **THEN** the model preserves the target, base constraint, and individual described exclusion/preference rules
|
||||
|
||||
#### Scenario: Repeated exclusion and preference rules
|
||||
- **WHEN** multiple `EXCLUDES` or `PREFERRED` lines refer to one declared target
|
||||
- **THEN** their expression sets form unions while every line and optional description remains individually inspectable
|
||||
|
||||
#### Scenario: Duplicate base dependency
|
||||
- **WHEN** the same dependency target has two `DEPENDS_ON` entries
|
||||
- **THEN** parsing fails
|
||||
|
||||
#### Scenario: Orphan relationship rule
|
||||
- **WHEN** an `EXCLUDES` or `PREFERRED` target has no corresponding `DEPENDS_ON`
|
||||
- **THEN** parsing fails
|
||||
|
||||
#### Scenario: Invalid preferred subset
|
||||
- **WHEN** any preferred version lies outside the base dependency set or inside an exclusion
|
||||
- **THEN** parsing fails because preference cannot make an invalid version valid
|
||||
|
||||
#### Scenario: Unsupported dependency scheme
|
||||
- **WHEN** a dependency uses `maven:` or another unsupported scheme in format version 1
|
||||
- **THEN** parsing fails clearly rather than ignoring or resolving it
|
||||
|
||||
### Requirement: Artifact policy
|
||||
An optional `[POLICY]` section SHALL preserve individual `BLACKLIST`, `DISCOURAGED`, and `RECOMMENDED` rules and descriptions. Blacklist SHALL be a hard constraint, discouraged and recommended SHALL be hints, overlaps SHALL be valid, and each recommended rule SHALL identify exactly one exact release present in the same snapshot. These recommendation invariants SHALL be enforced both while parsing and when the public model types are constructed directly without the parser.
|
||||
|
||||
#### Scenario: Valid overlapping policy
|
||||
- **WHEN** a broad discouraged range overlaps a blacklisted exact release and an existing exact release is recommended
|
||||
- **THEN** all individual rules and descriptions remain inspectable and the blacklist has the documented stronger meaning without flattening the rules
|
||||
|
||||
#### Scenario: Repeated policy entries
|
||||
- **WHEN** policy contains several blacklist, discouraged, or recommended lines
|
||||
- **THEN** all lines are retained as immutable individual rules
|
||||
|
||||
#### Scenario: Invalid recommendation
|
||||
- **WHEN** `RECOMMENDED` contains a non-exact range or identifies a release absent from the snapshot
|
||||
- **THEN** parsing fails
|
||||
|
||||
#### Scenario: Direct policy construction rejects a non-exact recommendation
|
||||
- **WHEN** a caller constructs `JournalPolicy` directly with a recommended rule containing a range, a minor series, multiple versions, or any other non-exact `VersionExpressionSet`
|
||||
- **THEN** construction fails because one recommended rule must identify exactly one exact `major.minor.patch` version
|
||||
|
||||
#### Scenario: Direct Journal construction rejects an absent recommendation
|
||||
- **WHEN** a caller constructs `Journal` directly with an exact recommended version that is absent from that Journal's release map
|
||||
- **THEN** construction fails because recommendations refer only to releases in the same snapshot
|
||||
|
||||
#### Scenario: Direct construction preserves valid policy overlaps
|
||||
- **WHEN** a caller constructs a Journal with an existing exact release recommended while that version also belongs to discouraged or blacklist rules
|
||||
- **THEN** construction succeeds and all individual rules remain inspectable because overlaps are valid
|
||||
|
||||
#### Scenario: Optional description decoding
|
||||
- **WHEN** a repeatable policy or dependency rule ends with `:'Known \'issue\' on C:\\tmp'`
|
||||
- **THEN** its decoded non-empty description is `Known 'issue' on C:\tmp`
|
||||
|
||||
#### Scenario: Invalid optional description
|
||||
- **WHEN** an explicitly supplied description is empty, unquoted, unterminated, or has malformed escaping
|
||||
- **THEN** parsing fails
|
||||
|
||||
### Requirement: Complete diagnostics and immutable results
|
||||
Parsing SHALL validate the entire document before it is accepted, SHALL report the known logical source, one-based line number, and reason for every parse failure, and SHALL return immutable defensively copied objects and collections without using `null` for optional values.
|
||||
|
||||
#### Scenario: Source-aware failure
|
||||
- **WHEN** line 12 of logical source `example.journal` contains an invalid field
|
||||
- **THEN** the parse exception identifies `example.journal`, line 12, and the validation reason
|
||||
|
||||
#### Scenario: Immutable model
|
||||
- **WHEN** callers obtain metadata, releases, policies, dependencies, digests, or expression collections
|
||||
- **THEN** callers cannot mutate the Journal snapshot through those returned values
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
## Purpose
|
||||
|
||||
Defines the public Journal query service, its lifecycle manager, internal atomic chain ingestion, historical query behavior, and explicit filesystem loading of immutable Journal revisions.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Strict Journal API package boundary
|
||||
The public Journal API SHALL begin at `com.r35157.nenjim.service.journal`, where the only directly contained Java types SHALL be the public interfaces `JournalService` and `JournalServiceManager`. Public composed domain objects SHALL be under `.model`, public value types SHALL be under `.valuetypes`, public Journal-specific exceptions SHALL be under `.exception`, and the reference implementation and its parser, ingestion, chain-state, and filesystem helpers SHALL be under `.impl.ref` or its subpackages without leaking implementation types through public signatures.
|
||||
|
||||
#### Scenario: Public package contents
|
||||
- **WHEN** the compiled Journal API packages are inspected
|
||||
- **THEN** only `JournalService` and `JournalServiceManager` are directly in `com.r35157.nenjim.service.journal`, while composed models, value types, and exceptions are in their respective public subpackages
|
||||
|
||||
#### Scenario: Reference implementation boundary
|
||||
- **WHEN** the public service and model signatures are inspected
|
||||
- **THEN** they expose no parser, mutable ingestion API, filesystem loader, chain store, or reference-implementation type
|
||||
|
||||
#### Scenario: Superseded API removed
|
||||
- **WHEN** the source and compiled Journal API are inspected
|
||||
- **THEN** no public `JournalManager`, deprecated alias, forwarding wrapper, or compatibility type remains under `com.r35157.nenjim.journal` or another superseded package
|
||||
|
||||
### Requirement: Filesystem-independent read-only Journal service
|
||||
`JournalService` SHALL be the ordinary application-facing, filesystem-independent, read-only API. It SHALL list known artifact coordinates and chronological revisions; obtain latest, exact, and newest-at-or-before revisions; obtain an exact artifact release from a selected revision; return immutable results without `null`; and expose no lifecycle, path, refresh, parser, configuration, or mutation operation.
|
||||
|
||||
#### Scenario: Chronological and latest queries
|
||||
- **WHEN** an artifact has several accepted revisions
|
||||
- **THEN** the service lists all complete snapshots in ascending Journal-version order and returns the chain head as latest
|
||||
|
||||
#### Scenario: Exact and as-of historical queries
|
||||
- **WHEN** a caller requests an exact revision or the newest revision at or before a Journal version
|
||||
- **THEN** the service returns the complete worldview from that chain position rather than filtering a newer snapshot by release publication time
|
||||
|
||||
#### Scenario: Release query from selected worldview
|
||||
- **WHEN** a caller requests an exact artifact version from a selected revision
|
||||
- **THEN** the release, including license, content, dependencies, and policy context in that snapshot, is returned only if that revision knows it
|
||||
|
||||
#### Scenario: Missing query result
|
||||
- **WHEN** a coordinate, revision, as-of revision, or artifact release is unknown
|
||||
- **THEN** the service returns an empty optional or immutable empty collection rather than `null`
|
||||
|
||||
#### Scenario: Read-only boundary
|
||||
- **WHEN** an ordinary consumer receives `JournalService`
|
||||
- **THEN** the interface provides no start, stop, filesystem root, refresh, parsing, update, ingestion, or other mutation operation
|
||||
|
||||
### Requirement: Internal atomic text ingestion and chain identity
|
||||
The reference service implementation SHALL keep mutable ingestion non-public and filesystem-independent. One ingestion SHALL reject null, empty, or blank input; parse and validate the complete text; compute SHA-256 over its exact UTF-8 bytes including whitespace and line endings; validate its chain relationship; and atomically expose the immutable Journal through `JournalService`. A failed ingestion SHALL leave every previously accepted revision unchanged for that operation.
|
||||
|
||||
#### Scenario: Valid internal ingestion
|
||||
- **WHEN** a complete valid genesis Journal is supplied internally
|
||||
- **THEN** it becomes immediately queryable through the read-only service under its metadata-derived coordinate
|
||||
|
||||
#### Scenario: Exact text digest
|
||||
- **WHEN** a Journal text is accepted
|
||||
- **THEN** its revision digest is calculated from exactly the supplied UTF-8 text, including whitespace and line endings
|
||||
|
||||
#### Scenario: Failed ingestion is atomic
|
||||
- **WHEN** an ingestion is null, empty, blank, structurally invalid, semantically invalid, or chain-invalid
|
||||
- **THEN** the operation fails and every previously accepted chain and revision remains unchanged
|
||||
|
||||
### Requirement: Linear per-artifact Journal chains
|
||||
Each artifact coordinate SHALL have exactly one genesis revision and one linear head. A non-genesis revision SHALL reference the current predecessor's exact SHA-256 digest and have a strictly greater Journal version. Missing, cross-artifact, stale-head, duplicate-genesis, or fork relationships SHALL fail.
|
||||
|
||||
#### Scenario: Valid successor
|
||||
- **WHEN** a later revision for the same coordinate references the current head's exact text digest and has a strictly greater Journal version
|
||||
- **THEN** it becomes the new head while the predecessor remains historically queryable
|
||||
|
||||
#### Scenario: Missing or cross-artifact predecessor
|
||||
- **WHEN** a non-genesis revision references an unknown digest or a digest owned by another artifact coordinate
|
||||
- **THEN** ingestion fails without changing either chain
|
||||
|
||||
#### Scenario: Fork or stale predecessor
|
||||
- **WHEN** a revision references a known non-head predecessor or attempts another genesis for an existing coordinate
|
||||
- **THEN** ingestion fails rather than creating multiple heads
|
||||
|
||||
#### Scenario: Non-increasing revision time
|
||||
- **WHEN** a successor Journal version is equal to or earlier than its predecessor's version
|
||||
- **THEN** ingestion fails
|
||||
|
||||
### Requirement: Idempotency and conflicts
|
||||
Supplying the same coordinate, Journal version, and exact complete text more than once SHALL be idempotent. Supplying different text for an existing coordinate and Journal version SHALL fail as a conflict even if the semantic model would be equal.
|
||||
|
||||
#### Scenario: Repeated identical text
|
||||
- **WHEN** an already accepted complete text is supplied again
|
||||
- **THEN** the existing immutable revision is returned without changing the chain
|
||||
|
||||
#### Scenario: Conflicting text
|
||||
- **WHEN** different text is supplied for an already stored coordinate and Journal version
|
||||
- **THEN** ingestion reports a conflict and does not mutate state
|
||||
|
||||
### Requirement: Journal service lifecycle manager
|
||||
`JournalServiceManager` SHALL be the lifecycle and administrative API. It SHALL start and stop the service, report the normalized configured Journal data root, perform explicit refresh, and expose the corresponding `JournalService` only after successful initialization. It SHALL not duplicate the Journal query API.
|
||||
|
||||
#### Scenario: Successful initial start
|
||||
- **WHEN** the configured root and all runtime Journal data are valid
|
||||
- **THEN** start performs the complete initial chronological load before the manager reports success and exposes its read-only service
|
||||
|
||||
#### Scenario: Failed initial start
|
||||
- **WHEN** the root, placement, filename, text, or chain is invalid during initial loading
|
||||
- **THEN** start fails clearly and the manager does not report a started service or expose a partially loaded service
|
||||
|
||||
#### Scenario: Service access follows lifecycle
|
||||
- **WHEN** service access or refresh is requested before successful start or after stop
|
||||
- **THEN** the manager rejects the operation as unavailable in the current lifecycle state
|
||||
|
||||
#### Scenario: Stop is non-destructive
|
||||
- **WHEN** the manager is stopped
|
||||
- **THEN** service-owned maintenance stops cleanly without creating, rewriting, or deleting Journal files
|
||||
|
||||
#### Scenario: Administrative API does not duplicate queries
|
||||
- **WHEN** `JournalServiceManager` is inspected
|
||||
- **THEN** artifact and revision query operations are available only through its associated `JournalService`
|
||||
|
||||
### Requirement: Filesystem-backed lifecycle loading
|
||||
The reference service manager SHALL own a configured existing readable root, discover runtime files only at `<root>/<artifact-coordinate>/<journal-version>.journal`, ignore `.journal.example`, validate directory and filename placement against parsed content, sort each artifact's files chronologically, read complete UTF-8 text, and supply it to the internal ingestion operation. It SHALL not create, overwrite, delete, publish, download, synchronize, or watch files.
|
||||
|
||||
#### Scenario: Valid filesystem load
|
||||
- **WHEN** an artifact directory contains chronologically named valid genesis and successor `.journal` files whose content identity matches their placement
|
||||
- **THEN** initial start or refresh ingests both complete UTF-8 texts in chronological order and exposes their chain through the service
|
||||
|
||||
#### Scenario: Example files are ignored
|
||||
- **WHEN** a directory contains `<journal-version>.journal.example`
|
||||
- **THEN** loading does not ingest it as a runtime Journal
|
||||
|
||||
#### Scenario: Invalid directory placement
|
||||
- **WHEN** a runtime Journal's metadata-derived coordinate differs from its parent directory name
|
||||
- **THEN** loading fails clearly
|
||||
|
||||
#### Scenario: Invalid filename placement
|
||||
- **WHEN** a runtime filename does not use the strict timestamp syntax or differs from the parsed `JOURNAL_VERSION`
|
||||
- **THEN** loading fails clearly
|
||||
|
||||
#### Scenario: Invalid root
|
||||
- **WHEN** the configured Journal root is missing, not a directory, or unreadable
|
||||
- **THEN** start or refresh fails clearly without creating it
|
||||
|
||||
#### Scenario: Unexpected filesystem entry
|
||||
- **WHEN** the configured root contains a runtime entry outside the artifact-directory/revision-file structure
|
||||
- **THEN** loading fails rather than silently treating it as a Journal
|
||||
|
||||
### Requirement: Explicit idempotent refresh
|
||||
The service manager SHALL support explicit refresh. Repeating refresh for unchanged files SHALL be idempotent, and a later refresh SHALL discover valid newly added successors while preserving existing historical revisions.
|
||||
|
||||
#### Scenario: Unchanged refresh
|
||||
- **WHEN** refresh runs twice without filesystem changes
|
||||
- **THEN** the second run leaves all chains unchanged and succeeds through ingestion idempotency
|
||||
|
||||
#### Scenario: New revision discovered
|
||||
- **WHEN** a valid successor file is added after an earlier successful refresh
|
||||
- **THEN** the next refresh ingests it and the read-only service exposes it as the new head
|
||||
|
||||
### Requirement: Version-controlled examples and migration
|
||||
Version-controlled examples SHALL use `<journal-version>.journal.example` beneath an artifact-coordinate directory and collectively demonstrate quoted description, a scheme-independent information URI, per-release license, dependencies, exclusions, preferences, policy, exact/range syntax, and optional descriptions. Any multi-revision example chain SHALL use the predecessor file's real exact-text SHA-256 digest.
|
||||
|
||||
#### Scenario: Example placement
|
||||
- **WHEN** repository Journal examples are inspected
|
||||
- **THEN** each is placed under `data/nenjim/journals/<artifact-coordinate>/` with a strict Journal-version filename ending in `.journal.example`
|
||||
|
||||
#### Scenario: Example chain integrity
|
||||
- **WHEN** examples include a successor revision
|
||||
- **THEN** its predecessor digest equals the SHA-256 of the predecessor example file's exact UTF-8 bytes
|
||||
@@ -0,0 +1,51 @@
|
||||
## 1. Immutable Journal Domain
|
||||
|
||||
- [x] 1.1 Implement artifact coordinates, Journal versions, content digests, metadata, releases, dependencies, policy rules, and complete Journal snapshots with immutable construction invariants.
|
||||
- [x] 1.2 Implement canonical version expressions and immutable expression-set membership, union, subtraction, and subset operations.
|
||||
|
||||
## 2. Format Version 1 Parsing
|
||||
|
||||
- [x] 2.1 Add the strict internal reference line parser and public Journal exception with source/line diagnostics, comment handling, ordering, duplicates, quoted text, URI, timestamp, release, and digest validation.
|
||||
- [x] 2.2 Add dependency and policy parsing plus cross-field validation for declared targets, preferred subsets, exact recommendations, and preserved descriptions.
|
||||
|
||||
## 3. Read-only Journal Service State
|
||||
|
||||
- [x] 3.1 Implement internal atomic filesystem-independent ingestion, exact-text SHA-256 identity, idempotency/conflicts, and linear per-artifact chain validation.
|
||||
- [x] 3.2 Implement immutable coordinate, chronological, latest, exact, as-of, and selected-release queries for the read-only service.
|
||||
|
||||
## 4. Lifecycle and Filesystem Service Manager
|
||||
|
||||
- [x] 4.1 Implement strict root/directory/filename discovery, UTF-8 chronological loading, placement validation, ignored example files, and explicit idempotent refresh in the service manager.
|
||||
- [x] 4.2 Separate ordinary read-only Journal queries from lifecycle, data-root, initial-loading, and refresh administration.
|
||||
|
||||
## 5. Migration and Documentation
|
||||
|
||||
- [x] 5.1 Move and rewrite the three artifact-scoped example Journals into revision directories with valid format-version-1 content covering the documented features.
|
||||
- [x] 5.2 Rewrite `docs/Nenjim.md`, `docs/Terminologi.md`, and `docs/Nenjim-public.html` for the implemented Journal model, `Service`/`ServiceManager` split, and clearly separated future functionality.
|
||||
- [x] 5.3 Remove the obsolete Journal/module/JournalId skeleton files, dummy behavior, imports, and dead references.
|
||||
|
||||
## 6. Verification
|
||||
|
||||
- [x] 6.1 Run strict OpenSpec validation and verify the change remains active, unsynced, and unarchived.
|
||||
- [x] 6.2 Compile the complete project through Gradle/Detag and run focused temporary probes covering example parsing, chain behavior, version expressions, invalid input diagnostics, and filesystem refresh.
|
||||
- [x] 6.3 Review the final diff for issue scope, out-of-scope exclusions, immutability, and absence of generated-source edits.
|
||||
|
||||
## 7. CIDv1 Canonicality Follow-up
|
||||
|
||||
- [x] 7.1 Require exact canonical unpadded lowercase Base32 round-tripping and minimal, overflow-safe unsigned varints for every CIDv1 envelope field.
|
||||
- [x] 7.2 Remove the obsolete `JournalId` tag from the versioned and active Detag configuration.
|
||||
- [x] 7.3 Run temporary CID canonicality probes, strict OpenSpec validation, `compileJava`, `assemble`, and `git diff --check`, then verify that the change remains active, unsynced, and unarchived.
|
||||
|
||||
## 8. Service Architecture Migration
|
||||
|
||||
- [x] 8.1 Reconcile proposal, design, and delta specs with the updated authoritative issue and remove the old `JournalManager`/filesystem-`JournalService` responsibility split.
|
||||
- [x] 8.2 Move the public composed models, value types, and exception into their mandated packages; add only the two central service interfaces directly under `com.r35157.nenjim.service.journal`; remove the superseded package without aliases.
|
||||
- [x] 8.3 Implement `JournalServiceImpl`, lifecycle-aware `JournalServiceManagerImpl`, and internal `JournalTextParser` with non-public ingestion, complete initial loading, refresh, chain atomicity, and existing query behavior.
|
||||
- [x] 8.4 Update the three required documents and terminology for the general `Service`/`ServiceManager` concepts, Journal responsibilities, package boundary, and current/future scope.
|
||||
- [x] 8.5 Run all required build, example, package-boundary, lifecycle, ingestion, chain/query, OpenSpec, generated-source, and diff verifications while leaving the change active, unsynced, and unarchived.
|
||||
|
||||
## 9. Direct Recommendation Construction Invariants
|
||||
|
||||
- [x] 9.1 Specify that direct `JournalPolicy` construction rejects non-exact recommendations and direct `Journal` construction rejects recommendations for releases absent from the same snapshot.
|
||||
- [x] 9.2 Enforce the recommendation invariants in the public model constructors while retaining parser diagnostics and valid policy overlaps.
|
||||
- [x] 9.3 Run temporary direct-construction and example-parsing probes, strict OpenSpec validation, `compileJava`, `assemble`, `git diff --check`, and generated-source checks while leaving the change active, unsynced, and unarchived.
|
||||
@@ -0,0 +1,215 @@
|
||||
# nenjim-journal-format Specification
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the observable format-version-1 contract and immutable domain meaning of a complete, artifact-scoped Nenjim Journal snapshot.
|
||||
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Artifact-scoped complete snapshots
|
||||
The Journal format SHALL describe exactly one artifact identified by the coordinate `<GROUP>-<MODULE>-<ARTIFACT>`, SHALL contain no `JOURNAL_ID` or artifact `TYPE`, and SHALL represent a complete immutable worldview rather than a delta from its predecessor.
|
||||
|
||||
#### Scenario: Valid artifact identity
|
||||
- **WHEN** metadata contains non-empty `GROUP=com_r35157_nenjim`, `MODULE=hubd`, and `ARTIFACT=api`
|
||||
- **THEN** the parsed coordinate is `com_r35157_nenjim-hubd-api`
|
||||
|
||||
#### Scenario: Obsolete identity fields
|
||||
- **WHEN** a Journal contains `JOURNAL_ID` or `TYPE`
|
||||
- **THEN** parsing fails as an unknown or misplaced field without silently ignoring it
|
||||
|
||||
#### Scenario: No-op semantic revision
|
||||
- **WHEN** a later valid snapshot differs from its predecessor only by `JOURNAL_VERSION` and `PREVIOUS_JOURNAL_DIGEST`
|
||||
- **THEN** the snapshot remains valid because revisions are complete worldviews and need not change their semantic content
|
||||
|
||||
### Requirement: Strict top-level structure and comments
|
||||
The parser SHALL require the logical order `FORMAT_VERSION=1`, `JOURNAL_VERSION`, optional `PREVIOUS_JOURNAL_DIGEST`, `[METADATA]`, optional `[POLICY]`, and one or more `[RELEASE <version>]` sections. Blank lines and full-line or trailing `#` comments SHALL be accepted, except that a `#` inside a quoted value is data.
|
||||
|
||||
#### Scenario: Comments before format entry
|
||||
- **WHEN** blank lines and full-line comments precede `FORMAT_VERSION=1`
|
||||
- **THEN** the format entry is treated as the first actual configuration entry and parsing proceeds
|
||||
|
||||
#### Scenario: Hash inside quoted text
|
||||
- **WHEN** `DESCRIPTION='Project #1' # trailing comment` is parsed
|
||||
- **THEN** the decoded description is `Project #1` and the trailing comment is discarded
|
||||
|
||||
#### Scenario: Unsupported format
|
||||
- **WHEN** the first actual entry is `FORMAT_VERSION=2`
|
||||
- **THEN** parsing fails explicitly because the format version is unsupported
|
||||
|
||||
#### Scenario: Misordered or duplicate structure
|
||||
- **WHEN** a required entry or section is missing, duplicated, malformed, unknown, or placed outside its defined order
|
||||
- **THEN** parsing fails and no unknown content is ignored
|
||||
|
||||
### Requirement: Journal and publication timestamps
|
||||
`JOURNAL_VERSION` and `PUBLISHED_AT` SHALL each use a strict UTC `uuuuMMddHHmmssSSS'Z'` timestamp with millisecond precision and SHALL retain distinct revision-time and release-time meanings.
|
||||
|
||||
#### Scenario: Valid timestamp
|
||||
- **WHEN** `20260819121530842Z` is supplied as a Journal or publication timestamp
|
||||
- **THEN** it is represented as the corresponding immutable UTC instant/value
|
||||
|
||||
#### Scenario: Invalid timestamp
|
||||
- **WHEN** a timestamp has missing milliseconds, a non-UTC suffix, an impossible calendar value, or trailing characters
|
||||
- **THEN** parsing fails
|
||||
|
||||
### Requirement: Quoted metadata values
|
||||
`[METADATA]` SHALL contain exactly one non-empty `GROUP`, `MODULE`, `ARTIFACT`, and single-quoted `DESCRIPTION`, and MAY contain at most one single-quoted `INFORMATION_URI`. The parser SHALL decode `\'` and `\\`, reject other malformed escaping and empty quoted values, and expose information URI as `Optional<URI>` only when the decoded value is a syntactically valid non-empty absolute URI, without restricting its scheme.
|
||||
|
||||
#### Scenario: Valid quoted metadata
|
||||
- **WHEN** metadata contains `DESCRIPTION='Nenjim\'s reference hub'` and `INFORMATION_URI='ftp://ftp.domain.com/components/123.txt'`
|
||||
- **THEN** the model exposes description `Nenjim's reference hub` and the exact absolute FTP URI
|
||||
|
||||
#### Scenario: Optional information URI absent
|
||||
- **WHEN** valid metadata omits `INFORMATION_URI`
|
||||
- **THEN** the model exposes an empty optional URI
|
||||
|
||||
#### Scenario: Scheme-independent URI
|
||||
- **WHEN** a syntactically valid absolute `https:`, `ftp:`, or `ipfs:` URI is supplied
|
||||
- **THEN** parsing accepts it without fetching, opening, or scheme-specific interpretation
|
||||
|
||||
#### Scenario: Invalid metadata text or URI
|
||||
- **WHEN** a required quoted value is unquoted, empty, unterminated, badly escaped, or the information URI is relative or syntactically invalid
|
||||
- **THEN** parsing fails at that field
|
||||
|
||||
### Requirement: Exact artifact releases and content
|
||||
Every release section SHALL identify a unique exact `major.minor.patch` stable semantic version and SHALL contain exactly one non-empty quoted `LICENSE`, exactly one valid `PUBLISHED_AT`, one or more supported unique `CONTENT_DIGEST` values, and exactly one non-negative long `CONTENT_SIZE`.
|
||||
|
||||
#### Scenario: Valid release content
|
||||
- **WHEN** release `1.0.3` contains decoded license text, a valid publication timestamp, a SHA-256 digest, an optional CIDv1 digest, and a non-negative content size
|
||||
- **THEN** the immutable release model exposes all values without normalizing or interpreting the license
|
||||
|
||||
#### Scenario: Invalid release version
|
||||
- **WHEN** a release heading uses `1`, `1.0`, `1.0.0-beta`, or `1.0.0+build.42`
|
||||
- **THEN** parsing fails rather than normalizing it to another exact release
|
||||
|
||||
#### Scenario: Duplicate release
|
||||
- **WHEN** two release sections identify the same exact version
|
||||
- **THEN** parsing fails
|
||||
|
||||
#### Scenario: Invalid license
|
||||
- **WHEN** a release omits `LICENSE`, repeats it, or supplies an unquoted, malformed, or empty value
|
||||
- **THEN** parsing fails
|
||||
|
||||
#### Scenario: Digest validation
|
||||
- **WHEN** content digests use valid `sha256:<64 lowercase hexadecimal characters>` and/or `cid1:<CIDv1 base32 text>` values whose payload is the exact canonical unpadded lowercase Base32 encoding of a valid CIDv1 envelope, with at most one digest of each scheme
|
||||
- **THEN** all supplied content identities remain inspectable in the release model
|
||||
|
||||
#### Scenario: Canonical CIDv1 envelope
|
||||
- **WHEN** a CIDv1 content digest is supplied
|
||||
- **THEN** its version, multicodec, multihash code, and digest-length fields use their shortest unsigned varint encoding within the supported `long` range, and the declared digest length matches the payload
|
||||
|
||||
#### Scenario: Non-canonical CIDv1
|
||||
- **WHEN** a CIDv1 spelling has non-zero unused Base32 padding bits, a non-minimal varint, or a varint that overflows the supported `long` range in any envelope field
|
||||
- **THEN** parsing fails even if a permissive decoder could produce the same CID bytes or a truncated numeric value
|
||||
|
||||
#### Scenario: Invalid content identity
|
||||
- **WHEN** no content digest exists, a digest scheme is unknown, a digest syntax is invalid, a scheme repeats, or content size is negative or outside the long range
|
||||
- **THEN** parsing fails
|
||||
|
||||
### Requirement: Normalized version expressions
|
||||
The model SHALL distinguish exact artifact versions from partial version boundaries, normalize semantically equivalent selector syntax, support membership and set containment, and reject unsupported or empty expressions.
|
||||
|
||||
#### Scenario: Equivalent exact forms
|
||||
- **WHEN** `1.0.3` and `[1.0.3]` are parsed
|
||||
- **THEN** both produce the same exact version-expression representation
|
||||
|
||||
#### Scenario: Minor series
|
||||
- **WHEN** `[1.0]` is parsed
|
||||
- **THEN** it contains every stable `1.0.x` release from `1.0.0` through the release immediately before `1.1.0` and is not normalized to exact `1.0.0`
|
||||
|
||||
#### Scenario: Inclusive and exclusive ranges
|
||||
- **WHEN** `[1.0.3-->1.0.7]` and `[1.0.3-->1.0.7)` are parsed
|
||||
- **THEN** the first contains both endpoints and the second excludes `1.0.7`
|
||||
|
||||
#### Scenario: Partial range boundaries
|
||||
- **WHEN** `[1.0-->1.2]` or `[1.0-->1.2)` is parsed
|
||||
- **THEN** the inclusive form contains the entire `1.2.x` series and the exclusive form excludes the entire `1.2.x` series
|
||||
|
||||
#### Scenario: Explicit cross-major range
|
||||
- **WHEN** `[1.0-->3.0)` is parsed
|
||||
- **THEN** it contains stable versions in major lines 1 and 2 but none in major line 3
|
||||
|
||||
#### Scenario: Open upper range
|
||||
- **WHEN** `[1.0-->`, `(1.0-->`, or `[1.0.3-->` is parsed
|
||||
- **THEN** its implicit exclusive upper boundary is `2.0.0`, while its lower boundary respectively starts at `1.0.0`, `1.1.0`, or `1.0.3`
|
||||
|
||||
#### Scenario: Expression union
|
||||
- **WHEN** `[1.0.0-->1.0.3], [1.0.7], 1.1.1` is parsed for a repeatable/list-valued rule
|
||||
- **THEN** the model represents the normalized union and accepts whitespace around commas and `-->`
|
||||
|
||||
#### Scenario: Invalid expression
|
||||
- **WHEN** an expression uses a major-only boundary, a bare incomplete exact version, prerelease/build syntax, mathematical comparison syntax, whitespace inside a numeric version, an empty list element, reversed boundaries, or an empty range
|
||||
- **THEN** parsing fails
|
||||
|
||||
### Requirement: Nenjim dependencies
|
||||
Each artifact release SHALL model each `nenjim:<artifact-coordinate>` dependency target with exactly one `DEPENDS_ON` base expression set, zero or more described `EXCLUDES` entries, and zero or more described `PREFERRED` entries. Unknown schemes SHALL fail explicitly, exclusions and preferences SHALL require a corresponding base dependency, and the complete preferred set SHALL be contained by `DEPENDS_ON - EXCLUDES`.
|
||||
|
||||
#### Scenario: Valid dependency relationship
|
||||
- **WHEN** a release depends on `nenjim:com_r35157_nenjim-hubd-api=[1.0-->`, excludes `1.0.4` with a description, and prefers `1.0.3`
|
||||
- **THEN** the model preserves the target, base constraint, and individual described exclusion/preference rules
|
||||
|
||||
#### Scenario: Repeated exclusion and preference rules
|
||||
- **WHEN** multiple `EXCLUDES` or `PREFERRED` lines refer to one declared target
|
||||
- **THEN** their expression sets form unions while every line and optional description remains individually inspectable
|
||||
|
||||
#### Scenario: Duplicate base dependency
|
||||
- **WHEN** the same dependency target has two `DEPENDS_ON` entries
|
||||
- **THEN** parsing fails
|
||||
|
||||
#### Scenario: Orphan relationship rule
|
||||
- **WHEN** an `EXCLUDES` or `PREFERRED` target has no corresponding `DEPENDS_ON`
|
||||
- **THEN** parsing fails
|
||||
|
||||
#### Scenario: Invalid preferred subset
|
||||
- **WHEN** any preferred version lies outside the base dependency set or inside an exclusion
|
||||
- **THEN** parsing fails because preference cannot make an invalid version valid
|
||||
|
||||
#### Scenario: Unsupported dependency scheme
|
||||
- **WHEN** a dependency uses `maven:` or another unsupported scheme in format version 1
|
||||
- **THEN** parsing fails clearly rather than ignoring or resolving it
|
||||
|
||||
### Requirement: Artifact policy
|
||||
An optional `[POLICY]` section SHALL preserve individual `BLACKLIST`, `DISCOURAGED`, and `RECOMMENDED` rules and descriptions. Blacklist SHALL be a hard constraint, discouraged and recommended SHALL be hints, overlaps SHALL be valid, and each recommended rule SHALL identify exactly one exact release present in the same snapshot. These recommendation invariants SHALL be enforced both while parsing and when the public model types are constructed directly without the parser.
|
||||
|
||||
#### Scenario: Valid overlapping policy
|
||||
- **WHEN** a broad discouraged range overlaps a blacklisted exact release and an existing exact release is recommended
|
||||
- **THEN** all individual rules and descriptions remain inspectable and the blacklist has the documented stronger meaning without flattening the rules
|
||||
|
||||
#### Scenario: Repeated policy entries
|
||||
- **WHEN** policy contains several blacklist, discouraged, or recommended lines
|
||||
- **THEN** all lines are retained as immutable individual rules
|
||||
|
||||
#### Scenario: Invalid recommendation
|
||||
- **WHEN** `RECOMMENDED` contains a non-exact range or identifies a release absent from the snapshot
|
||||
- **THEN** parsing fails
|
||||
|
||||
#### Scenario: Direct policy construction rejects a non-exact recommendation
|
||||
- **WHEN** a caller constructs `JournalPolicy` directly with a recommended rule containing a range, a minor series, multiple versions, or any other non-exact `VersionExpressionSet`
|
||||
- **THEN** construction fails because one recommended rule must identify exactly one exact `major.minor.patch` version
|
||||
|
||||
#### Scenario: Direct Journal construction rejects an absent recommendation
|
||||
- **WHEN** a caller constructs `Journal` directly with an exact recommended version that is absent from that Journal's release map
|
||||
- **THEN** construction fails because recommendations refer only to releases in the same snapshot
|
||||
|
||||
#### Scenario: Direct construction preserves valid policy overlaps
|
||||
- **WHEN** a caller constructs a Journal with an existing exact release recommended while that version also belongs to discouraged or blacklist rules
|
||||
- **THEN** construction succeeds and all individual rules remain inspectable because overlaps are valid
|
||||
|
||||
#### Scenario: Optional description decoding
|
||||
- **WHEN** a repeatable policy or dependency rule ends with `:'Known \'issue\' on C:\\tmp'`
|
||||
- **THEN** its decoded non-empty description is `Known 'issue' on C:\tmp`
|
||||
|
||||
#### Scenario: Invalid optional description
|
||||
- **WHEN** an explicitly supplied description is empty, unquoted, unterminated, or has malformed escaping
|
||||
- **THEN** parsing fails
|
||||
|
||||
### Requirement: Complete diagnostics and immutable results
|
||||
Parsing SHALL validate the entire document before it is accepted, SHALL report the known logical source, one-based line number, and reason for every parse failure, and SHALL return immutable defensively copied objects and collections without using `null` for optional values.
|
||||
|
||||
#### Scenario: Source-aware failure
|
||||
- **WHEN** line 12 of logical source `example.journal` contains an invalid field
|
||||
- **THEN** the parse exception identifies `example.journal`, line 12, and the validation reason
|
||||
|
||||
#### Scenario: Immutable model
|
||||
- **WHEN** callers obtain metadata, releases, policies, dependencies, digests, or expression collections
|
||||
- **THEN** callers cannot mutate the Journal snapshot through those returned values
|
||||
@@ -0,0 +1,163 @@
|
||||
# nenjim-journal-management Specification
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the public Journal query service, its lifecycle manager, internal atomic chain ingestion, historical query behavior, and explicit filesystem loading of immutable Journal revisions.
|
||||
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Strict Journal API package boundary
|
||||
The public Journal API SHALL begin at `com.r35157.nenjim.service.journal`, where the only directly contained Java types SHALL be the public interfaces `JournalService` and `JournalServiceManager`. Public composed domain objects SHALL be under `.model`, public value types SHALL be under `.valuetypes`, public Journal-specific exceptions SHALL be under `.exception`, and the reference implementation and its parser, ingestion, chain-state, and filesystem helpers SHALL be under `.impl.ref` or its subpackages without leaking implementation types through public signatures.
|
||||
|
||||
#### Scenario: Public package contents
|
||||
- **WHEN** the compiled Journal API packages are inspected
|
||||
- **THEN** only `JournalService` and `JournalServiceManager` are directly in `com.r35157.nenjim.service.journal`, while composed models, value types, and exceptions are in their respective public subpackages
|
||||
|
||||
#### Scenario: Reference implementation boundary
|
||||
- **WHEN** the public service and model signatures are inspected
|
||||
- **THEN** they expose no parser, mutable ingestion API, filesystem loader, chain store, or reference-implementation type
|
||||
|
||||
#### Scenario: Superseded API removed
|
||||
- **WHEN** the source and compiled Journal API are inspected
|
||||
- **THEN** no public `JournalManager`, deprecated alias, forwarding wrapper, or compatibility type remains under `com.r35157.nenjim.journal` or another superseded package
|
||||
|
||||
### Requirement: Filesystem-independent read-only Journal service
|
||||
`JournalService` SHALL be the ordinary application-facing, filesystem-independent, read-only API. It SHALL list known artifact coordinates and chronological revisions; obtain latest, exact, and newest-at-or-before revisions; obtain an exact artifact release from a selected revision; return immutable results without `null`; and expose no lifecycle, path, refresh, parser, configuration, or mutation operation.
|
||||
|
||||
#### Scenario: Chronological and latest queries
|
||||
- **WHEN** an artifact has several accepted revisions
|
||||
- **THEN** the service lists all complete snapshots in ascending Journal-version order and returns the chain head as latest
|
||||
|
||||
#### Scenario: Exact and as-of historical queries
|
||||
- **WHEN** a caller requests an exact revision or the newest revision at or before a Journal version
|
||||
- **THEN** the service returns the complete worldview from that chain position rather than filtering a newer snapshot by release publication time
|
||||
|
||||
#### Scenario: Release query from selected worldview
|
||||
- **WHEN** a caller requests an exact artifact version from a selected revision
|
||||
- **THEN** the release, including license, content, dependencies, and policy context in that snapshot, is returned only if that revision knows it
|
||||
|
||||
#### Scenario: Missing query result
|
||||
- **WHEN** a coordinate, revision, as-of revision, or artifact release is unknown
|
||||
- **THEN** the service returns an empty optional or immutable empty collection rather than `null`
|
||||
|
||||
#### Scenario: Read-only boundary
|
||||
- **WHEN** an ordinary consumer receives `JournalService`
|
||||
- **THEN** the interface provides no start, stop, filesystem root, refresh, parsing, update, ingestion, or other mutation operation
|
||||
|
||||
### Requirement: Internal atomic text ingestion and chain identity
|
||||
The reference service implementation SHALL keep mutable ingestion non-public and filesystem-independent. One ingestion SHALL reject null, empty, or blank input; parse and validate the complete text; compute SHA-256 over its exact UTF-8 bytes including whitespace and line endings; validate its chain relationship; and atomically expose the immutable Journal through `JournalService`. A failed ingestion SHALL leave every previously accepted revision unchanged for that operation.
|
||||
|
||||
#### Scenario: Valid internal ingestion
|
||||
- **WHEN** a complete valid genesis Journal is supplied internally
|
||||
- **THEN** it becomes immediately queryable through the read-only service under its metadata-derived coordinate
|
||||
|
||||
#### Scenario: Exact text digest
|
||||
- **WHEN** a Journal text is accepted
|
||||
- **THEN** its revision digest is calculated from exactly the supplied UTF-8 text, including whitespace and line endings
|
||||
|
||||
#### Scenario: Failed ingestion is atomic
|
||||
- **WHEN** an ingestion is null, empty, blank, structurally invalid, semantically invalid, or chain-invalid
|
||||
- **THEN** the operation fails and every previously accepted chain and revision remains unchanged
|
||||
|
||||
### Requirement: Linear per-artifact Journal chains
|
||||
Each artifact coordinate SHALL have exactly one genesis revision and one linear head. A non-genesis revision SHALL reference the current predecessor's exact SHA-256 digest and have a strictly greater Journal version. Missing, cross-artifact, stale-head, duplicate-genesis, or fork relationships SHALL fail.
|
||||
|
||||
#### Scenario: Valid successor
|
||||
- **WHEN** a later revision for the same coordinate references the current head's exact text digest and has a strictly greater Journal version
|
||||
- **THEN** it becomes the new head while the predecessor remains historically queryable
|
||||
|
||||
#### Scenario: Missing or cross-artifact predecessor
|
||||
- **WHEN** a non-genesis revision references an unknown digest or a digest owned by another artifact coordinate
|
||||
- **THEN** ingestion fails without changing either chain
|
||||
|
||||
#### Scenario: Fork or stale predecessor
|
||||
- **WHEN** a revision references a known non-head predecessor or attempts another genesis for an existing coordinate
|
||||
- **THEN** ingestion fails rather than creating multiple heads
|
||||
|
||||
#### Scenario: Non-increasing revision time
|
||||
- **WHEN** a successor Journal version is equal to or earlier than its predecessor's version
|
||||
- **THEN** ingestion fails
|
||||
|
||||
### Requirement: Idempotency and conflicts
|
||||
Supplying the same coordinate, Journal version, and exact complete text more than once SHALL be idempotent. Supplying different text for an existing coordinate and Journal version SHALL fail as a conflict even if the semantic model would be equal.
|
||||
|
||||
#### Scenario: Repeated identical text
|
||||
- **WHEN** an already accepted complete text is supplied again
|
||||
- **THEN** the existing immutable revision is returned without changing the chain
|
||||
|
||||
#### Scenario: Conflicting text
|
||||
- **WHEN** different text is supplied for an already stored coordinate and Journal version
|
||||
- **THEN** ingestion reports a conflict and does not mutate state
|
||||
|
||||
### Requirement: Journal service lifecycle manager
|
||||
`JournalServiceManager` SHALL be the lifecycle and administrative API. It SHALL start and stop the service, report the normalized configured Journal data root, perform explicit refresh, and expose the corresponding `JournalService` only after successful initialization. It SHALL not duplicate the Journal query API.
|
||||
|
||||
#### Scenario: Successful initial start
|
||||
- **WHEN** the configured root and all runtime Journal data are valid
|
||||
- **THEN** start performs the complete initial chronological load before the manager reports success and exposes its read-only service
|
||||
|
||||
#### Scenario: Failed initial start
|
||||
- **WHEN** the root, placement, filename, text, or chain is invalid during initial loading
|
||||
- **THEN** start fails clearly and the manager does not report a started service or expose a partially loaded service
|
||||
|
||||
#### Scenario: Service access follows lifecycle
|
||||
- **WHEN** service access or refresh is requested before successful start or after stop
|
||||
- **THEN** the manager rejects the operation as unavailable in the current lifecycle state
|
||||
|
||||
#### Scenario: Stop is non-destructive
|
||||
- **WHEN** the manager is stopped
|
||||
- **THEN** service-owned maintenance stops cleanly without creating, rewriting, or deleting Journal files
|
||||
|
||||
#### Scenario: Administrative API does not duplicate queries
|
||||
- **WHEN** `JournalServiceManager` is inspected
|
||||
- **THEN** artifact and revision query operations are available only through its associated `JournalService`
|
||||
|
||||
### Requirement: Filesystem-backed lifecycle loading
|
||||
The reference service manager SHALL own a configured existing readable root, discover runtime files only at `<root>/<artifact-coordinate>/<journal-version>.journal`, ignore `.journal.example`, validate directory and filename placement against parsed content, sort each artifact's files chronologically, read complete UTF-8 text, and supply it to the internal ingestion operation. It SHALL not create, overwrite, delete, publish, download, synchronize, or watch files.
|
||||
|
||||
#### Scenario: Valid filesystem load
|
||||
- **WHEN** an artifact directory contains chronologically named valid genesis and successor `.journal` files whose content identity matches their placement
|
||||
- **THEN** initial start or refresh ingests both complete UTF-8 texts in chronological order and exposes their chain through the service
|
||||
|
||||
#### Scenario: Example files are ignored
|
||||
- **WHEN** a directory contains `<journal-version>.journal.example`
|
||||
- **THEN** loading does not ingest it as a runtime Journal
|
||||
|
||||
#### Scenario: Invalid directory placement
|
||||
- **WHEN** a runtime Journal's metadata-derived coordinate differs from its parent directory name
|
||||
- **THEN** loading fails clearly
|
||||
|
||||
#### Scenario: Invalid filename placement
|
||||
- **WHEN** a runtime filename does not use the strict timestamp syntax or differs from the parsed `JOURNAL_VERSION`
|
||||
- **THEN** loading fails clearly
|
||||
|
||||
#### Scenario: Invalid root
|
||||
- **WHEN** the configured Journal root is missing, not a directory, or unreadable
|
||||
- **THEN** start or refresh fails clearly without creating it
|
||||
|
||||
#### Scenario: Unexpected filesystem entry
|
||||
- **WHEN** the configured root contains a runtime entry outside the artifact-directory/revision-file structure
|
||||
- **THEN** loading fails rather than silently treating it as a Journal
|
||||
|
||||
### Requirement: Explicit idempotent refresh
|
||||
The service manager SHALL support explicit refresh. Repeating refresh for unchanged files SHALL be idempotent, and a later refresh SHALL discover valid newly added successors while preserving existing historical revisions.
|
||||
|
||||
#### Scenario: Unchanged refresh
|
||||
- **WHEN** refresh runs twice without filesystem changes
|
||||
- **THEN** the second run leaves all chains unchanged and succeeds through ingestion idempotency
|
||||
|
||||
#### Scenario: New revision discovered
|
||||
- **WHEN** a valid successor file is added after an earlier successful refresh
|
||||
- **THEN** the next refresh ingests it and the read-only service exposes it as the new head
|
||||
|
||||
### Requirement: Version-controlled examples and migration
|
||||
Version-controlled examples SHALL use `<journal-version>.journal.example` beneath an artifact-coordinate directory and collectively demonstrate quoted description, a scheme-independent information URI, per-release license, dependencies, exclusions, preferences, policy, exact/range syntax, and optional descriptions. Any multi-revision example chain SHALL use the predecessor file's real exact-text SHA-256 digest.
|
||||
|
||||
#### Scenario: Example placement
|
||||
- **WHEN** repository Journal examples are inspected
|
||||
- **THEN** each is placed under `data/nenjim/journals/<artifact-coordinate>/` with a strict Journal-version filename ending in `.journal.example`
|
||||
|
||||
#### Scenario: Example chain integrity
|
||||
- **WHEN** examples include a successor revision
|
||||
- **THEN** its predecessor digest equals the SHA-256 of the predecessor example file's exact UTF-8 bytes
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.r35157.nenjim.hubd;
|
||||
|
||||
import com.r35157.nenjim.hubd.journal.Journal;
|
||||
import crypto.r35157.nenjim.NenjimProcess;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
package com.r35157.nenjim.hubd.impl.ref;
|
||||
|
||||
import com.r35157.nenjim.hubd.journal.Journal;
|
||||
import com.r35157.nenjim.hubd.journal.JournalManager;
|
||||
|
||||
public class JournalManagerImpl implements JournalManager {
|
||||
@Override
|
||||
public Journal getJournal(String s) {
|
||||
return new Journal(null, null, null);
|
||||
}
|
||||
}
|
||||
@@ -2,26 +2,14 @@ package com.r35157.nenjim.hubd.impl.ref;
|
||||
|
||||
import com.r35157.libs.valuetypes.basic.SemanticVersion;
|
||||
import com.r35157.nenjim.hubd.ctx.Context;
|
||||
import com.r35157.nenjim.hubd.journal.Journal;
|
||||
import com.r35157.nenjim.hubd.journal.JournalManager;
|
||||
import com.r35157.nenjim.hubd.module.Release;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
public final class NenjimClassLoader extends ClassLoader {
|
||||
// TODO: Check protection - it is only main that can create a 'half' initialized classloader (and set the rest with setters afterwards)
|
||||
NenjimClassLoader(JournalManager journalManager, Context context) {
|
||||
this.journalManager = journalManager;
|
||||
NenjimClassLoader(Context context) {
|
||||
this.context = context;
|
||||
//moduleVersion = new SemanticVersion(0, 1, 0);
|
||||
}
|
||||
|
||||
private String getModuleName(String className) {
|
||||
return className.substring(0, className.lastIndexOf("."));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Class<?> findClass(String className) throws ClassNotFoundException {
|
||||
System.out.println("NenjimClassLoader asked to load '" + className + "'...");
|
||||
@@ -31,12 +19,9 @@ public final class NenjimClassLoader extends ClassLoader {
|
||||
|
||||
if(version == null) {
|
||||
// No local configurations for a specific version has been configured for this package
|
||||
// in this Context. Use the version the developer do recommend in the Journal.
|
||||
String moduleName = getModuleName(className);
|
||||
System.out.println(" Context (" + context.getName() + ") does NOT have any special version requirements for the module '" + moduleName + "' - use vendor recommendation from journal...");
|
||||
Journal journal = journalManager.getJournal(moduleName);
|
||||
Release release = journal.getRelease(version);
|
||||
System.out.println(" Journal did recommend version '" + version + "' for the module '" + moduleName + "'");
|
||||
// in this Context. Resolution and Journal integration are not implemented yet.
|
||||
System.out.println(" Context (" + context.getName()
|
||||
+ ") does NOT have a resolved version for '" + className + "'.");
|
||||
}
|
||||
System.out.println("Searching for class '" + className + "' in local Nenjim class cache...");
|
||||
String relativePath = className.replace('.', '/') + ".class";
|
||||
@@ -58,6 +43,5 @@ public final class NenjimClassLoader extends ClassLoader {
|
||||
return null;
|
||||
}
|
||||
|
||||
private JournalManager journalManager;
|
||||
private Context context;
|
||||
}
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
package com.r35157.nenjim.hubd.journal;
|
||||
|
||||
import com.r35157.libs.valuetypes.basic.SemanticVersion;
|
||||
import com.r35157.nenjim.hubd.module.Dependency;
|
||||
import com.r35157.nenjim.hubd.module.Release;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.util.Date;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
public record Journal(
|
||||
@NotNull ΩJournalIdΩ id,
|
||||
@NotNull String name,
|
||||
@NotNull Set<Release> releases
|
||||
) {
|
||||
public Release getRelease(SemanticVersion version) {
|
||||
Set<Dependency> dependencies = new HashSet<>();
|
||||
|
||||
return new Release(
|
||||
new SemanticVersion(0, 1, 0),
|
||||
new Date(),
|
||||
null,
|
||||
dependencies);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
package com.r35157.nenjim.hubd.journal;
|
||||
|
||||
public interface JournalManager {
|
||||
Journal getJournal(String moduleName);
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
package com.r35157.nenjim.hubd.module;
|
||||
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
public record Dependency(
|
||||
@NotNull ΩJournalIdΩ dependencyId
|
||||
) {
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
package com.r35157.nenjim.hubd.module;
|
||||
|
||||
public record Module() {
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
package com.r35157.nenjim.hubd.module;
|
||||
|
||||
import com.r35157.libs.valuetypes.basic.SemanticVersion;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.util.Date;
|
||||
import java.util.Set;
|
||||
|
||||
public record Release(
|
||||
@NotNull SemanticVersion version,
|
||||
@NotNull Date releaseDate,
|
||||
@NotNull ΩChecksumΩ checksum,
|
||||
@NotNull Set<Dependency> dependencies
|
||||
) {}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.r35157.nenjim.service.journal;
|
||||
|
||||
import com.r35157.libs.valuetypes.basic.SemanticVersion;
|
||||
import com.r35157.nenjim.service.journal.model.ArtifactRelease;
|
||||
import com.r35157.nenjim.service.journal.model.Journal;
|
||||
import com.r35157.nenjim.service.journal.valuetypes.ArtifactCoordinate;
|
||||
import com.r35157.nenjim.service.journal.valuetypes.JournalVersion;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
/** Filesystem-independent, read-only access to current and historical Journal knowledge. */
|
||||
public interface JournalService {
|
||||
/** Lists known artifact coordinates in stable lexical order. */
|
||||
Set<ArtifactCoordinate> artifactCoordinates();
|
||||
|
||||
/** Lists complete revisions in ascending Journal-version order. */
|
||||
List<Journal> revisions(ArtifactCoordinate coordinate);
|
||||
|
||||
/** Returns the current complete worldview for an artifact. */
|
||||
Optional<Journal> latest(ArtifactCoordinate coordinate);
|
||||
|
||||
/** Returns one exact historical complete worldview. */
|
||||
Optional<Journal> revision(ArtifactCoordinate coordinate, JournalVersion journalVersion);
|
||||
|
||||
/** Returns the newest complete worldview published no later than the requested Journal version. */
|
||||
Optional<Journal> revisionAtOrBefore(ArtifactCoordinate coordinate, JournalVersion journalVersion);
|
||||
|
||||
/** Returns an exact release from one selected Journal worldview. */
|
||||
Optional<ArtifactRelease> release(
|
||||
ArtifactCoordinate coordinate,
|
||||
JournalVersion journalVersion,
|
||||
SemanticVersion releaseVersion);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.r35157.nenjim.service.journal;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
|
||||
/** Lifecycle and administrative access for one filesystem-backed Journal service. */
|
||||
public interface JournalServiceManager {
|
||||
/** Starts the service and completes its initial Journal loading before returning. */
|
||||
void start() throws IOException;
|
||||
|
||||
/** Stops service-owned maintenance without changing Journal files. */
|
||||
void stop();
|
||||
|
||||
/** Returns the normalized configured Journal data root. */
|
||||
Path journalRoot();
|
||||
|
||||
/** Returns the read-only service after successful initialization. */
|
||||
JournalService journalService();
|
||||
|
||||
/** Explicitly reloads the configured runtime Journal files. */
|
||||
void refresh() throws IOException;
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package com.r35157.nenjim.service.journal.exception;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.OptionalInt;
|
||||
|
||||
/** Reports a structural, semantic, or chain error in a Journal update. */
|
||||
public final class InvalidJournalException extends IllegalArgumentException {
|
||||
private final String reason;
|
||||
private final Optional<String> sourceName;
|
||||
private final OptionalInt lineNumber;
|
||||
|
||||
public InvalidJournalException(String reason) {
|
||||
this(null, 0, reason, null);
|
||||
}
|
||||
|
||||
public InvalidJournalException(String sourceName, int lineNumber, String reason) {
|
||||
this(sourceName, lineNumber, reason, null);
|
||||
}
|
||||
|
||||
public InvalidJournalException(String sourceName, int lineNumber, String reason, Throwable cause) {
|
||||
super(formatMessage(sourceName, lineNumber, reason), cause);
|
||||
this.reason = Objects.requireNonNull(reason, "reason");
|
||||
this.sourceName = sourceName == null || sourceName.isBlank()
|
||||
? Optional.empty() : Optional.of(sourceName);
|
||||
if (lineNumber < 0) {
|
||||
throw new IllegalArgumentException("lineNumber cannot be negative");
|
||||
}
|
||||
this.lineNumber = lineNumber == 0 ? OptionalInt.empty() : OptionalInt.of(lineNumber);
|
||||
}
|
||||
|
||||
public String reason() {
|
||||
return reason;
|
||||
}
|
||||
|
||||
public Optional<String> sourceName() {
|
||||
return sourceName;
|
||||
}
|
||||
|
||||
public OptionalInt lineNumber() {
|
||||
return lineNumber;
|
||||
}
|
||||
|
||||
private static String formatMessage(String sourceName, int lineNumber, String reason) {
|
||||
Objects.requireNonNull(reason, "reason");
|
||||
String location = sourceName == null || sourceName.isBlank() ? "Journal" : sourceName;
|
||||
if (lineNumber > 0) location += ":" + lineNumber;
|
||||
return location + ": " + reason;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
package com.r35157.nenjim.service.journal.impl.ref;
|
||||
|
||||
import com.r35157.libs.valuetypes.basic.SemanticVersion;
|
||||
import com.r35157.nenjim.service.journal.JournalService;
|
||||
import com.r35157.nenjim.service.journal.exception.InvalidJournalException;
|
||||
import com.r35157.nenjim.service.journal.model.ArtifactRelease;
|
||||
import com.r35157.nenjim.service.journal.model.Journal;
|
||||
import com.r35157.nenjim.service.journal.valuetypes.ArtifactCoordinate;
|
||||
import com.r35157.nenjim.service.journal.valuetypes.ContentDigest;
|
||||
import com.r35157.nenjim.service.journal.valuetypes.JournalVersion;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.NavigableMap;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.TreeMap;
|
||||
import java.util.TreeSet;
|
||||
|
||||
/**
|
||||
* Filesystem-independent, thread-safe reference service for immutable per-artifact Journal chains.
|
||||
*/
|
||||
public final class JournalServiceImpl implements JournalService {
|
||||
private static final String DEFAULT_SOURCE = "<journal>";
|
||||
|
||||
private final Object stateLock = new Object();
|
||||
private final JournalTextParser parser;
|
||||
private final Map<ArtifactCoordinate, NavigableMap<JournalVersion, StoredRevision>> chains = new HashMap<>();
|
||||
private final Map<String, ArtifactCoordinate> digestOwners = new HashMap<>();
|
||||
|
||||
JournalServiceImpl() {
|
||||
this(new JournalTextParser());
|
||||
}
|
||||
|
||||
JournalServiceImpl(JournalTextParser parser) {
|
||||
this.parser = Objects.requireNonNull(parser, "parser");
|
||||
}
|
||||
|
||||
/** Parses and validates a complete snapshot without changing service state. */
|
||||
Journal parse(String completeJournalText) {
|
||||
return parse(completeJournalText, DEFAULT_SOURCE);
|
||||
}
|
||||
|
||||
/** Parses and validates a complete snapshot without changing service state. */
|
||||
Journal parse(String completeJournalText, String logicalSourceName) {
|
||||
return parser.parse(completeJournalText, sourceName(logicalSourceName));
|
||||
}
|
||||
|
||||
/** Atomically accepts one complete textual Journal snapshot. */
|
||||
Journal ingest(String completeJournalText) {
|
||||
return ingest(completeJournalText, DEFAULT_SOURCE);
|
||||
}
|
||||
|
||||
/** Atomically accepts one complete textual Journal snapshot with a diagnostic source name. */
|
||||
Journal ingest(String completeJournalText, String logicalSourceName) {
|
||||
String source = sourceName(logicalSourceName);
|
||||
Journal candidate = parser.parse(completeJournalText, source);
|
||||
ContentDigest digest = exactTextDigest(completeJournalText);
|
||||
ArtifactCoordinate coordinate = candidate.metadata().coordinate();
|
||||
JournalVersion version = candidate.journalVersion();
|
||||
|
||||
synchronized (stateLock) {
|
||||
NavigableMap<JournalVersion, StoredRevision> existingChain = chains.get(coordinate);
|
||||
StoredRevision existingRevision = existingChain == null ? null : existingChain.get(version);
|
||||
if (existingRevision != null) {
|
||||
if (existingRevision.completeText.equals(completeJournalText)) {
|
||||
return existingRevision.journal;
|
||||
}
|
||||
throw chainError(source,
|
||||
"Conflicting text already exists for " + coordinate + " at Journal version " + version);
|
||||
}
|
||||
|
||||
if (existingChain == null) {
|
||||
if (candidate.previousJournalDigest().isPresent()) {
|
||||
throw invalidPredecessor(source, coordinate, candidate.previousJournalDigest().orElseThrow());
|
||||
}
|
||||
} else {
|
||||
if (candidate.previousJournalDigest().isEmpty()) {
|
||||
throw chainError(source,
|
||||
"Artifact " + coordinate + " already has a genesis revision; a successor must name its predecessor");
|
||||
}
|
||||
|
||||
StoredRevision head = existingChain.lastEntry().getValue();
|
||||
if (version.compareTo(head.journal.journalVersion()) <= 0) {
|
||||
throw chainError(source,
|
||||
"Journal version " + version + " must be strictly greater than current head "
|
||||
+ head.journal.journalVersion() + " for " + coordinate);
|
||||
}
|
||||
|
||||
ContentDigest predecessor = candidate.previousJournalDigest().orElseThrow();
|
||||
if (!predecessor.equals(head.digest)) {
|
||||
throw invalidPredecessor(source, coordinate, predecessor);
|
||||
}
|
||||
}
|
||||
|
||||
TreeMap<JournalVersion, StoredRevision> updated = existingChain == null
|
||||
? new TreeMap<>() : new TreeMap<>(existingChain);
|
||||
updated.put(version, new StoredRevision(candidate, completeJournalText, digest));
|
||||
chains.put(coordinate, Collections.unmodifiableNavigableMap(updated));
|
||||
digestOwners.put(digest.value(), coordinate);
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
/** Lists known artifact coordinates in stable lexical order. */
|
||||
@Override
|
||||
public Set<ArtifactCoordinate> artifactCoordinates() {
|
||||
synchronized (stateLock) {
|
||||
TreeSet<ArtifactCoordinate> coordinates = new TreeSet<>(Comparator.comparing(ArtifactCoordinate::toString));
|
||||
coordinates.addAll(chains.keySet());
|
||||
return Collections.unmodifiableSet(coordinates);
|
||||
}
|
||||
}
|
||||
|
||||
/** Lists complete revisions in ascending Journal-version order. */
|
||||
@Override
|
||||
public List<Journal> revisions(ArtifactCoordinate coordinate) {
|
||||
Objects.requireNonNull(coordinate, "coordinate");
|
||||
synchronized (stateLock) {
|
||||
NavigableMap<JournalVersion, StoredRevision> chain = chains.get(coordinate);
|
||||
if (chain == null) return List.of();
|
||||
ArrayList<Journal> result = new ArrayList<>(chain.size());
|
||||
chain.values().forEach(stored -> result.add(stored.journal));
|
||||
return List.copyOf(result);
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns the current complete worldview for an artifact. */
|
||||
@Override
|
||||
public Optional<Journal> latest(ArtifactCoordinate coordinate) {
|
||||
Objects.requireNonNull(coordinate, "coordinate");
|
||||
synchronized (stateLock) {
|
||||
NavigableMap<JournalVersion, StoredRevision> chain = chains.get(coordinate);
|
||||
return chain == null ? Optional.empty() : Optional.of(chain.lastEntry().getValue().journal);
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns one exact historical complete worldview. */
|
||||
@Override
|
||||
public Optional<Journal> revision(ArtifactCoordinate coordinate, JournalVersion journalVersion) {
|
||||
Objects.requireNonNull(coordinate, "coordinate");
|
||||
Objects.requireNonNull(journalVersion, "journalVersion");
|
||||
synchronized (stateLock) {
|
||||
NavigableMap<JournalVersion, StoredRevision> chain = chains.get(coordinate);
|
||||
if (chain == null) return Optional.empty();
|
||||
StoredRevision stored = chain.get(journalVersion);
|
||||
return stored == null ? Optional.empty() : Optional.of(stored.journal);
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns the newest complete worldview published no later than the requested Journal version. */
|
||||
@Override
|
||||
public Optional<Journal> revisionAtOrBefore(
|
||||
ArtifactCoordinate coordinate,
|
||||
JournalVersion journalVersion) {
|
||||
Objects.requireNonNull(coordinate, "coordinate");
|
||||
Objects.requireNonNull(journalVersion, "journalVersion");
|
||||
synchronized (stateLock) {
|
||||
NavigableMap<JournalVersion, StoredRevision> chain = chains.get(coordinate);
|
||||
if (chain == null) return Optional.empty();
|
||||
Map.Entry<JournalVersion, StoredRevision> entry = chain.floorEntry(journalVersion);
|
||||
return entry == null ? Optional.empty() : Optional.of(entry.getValue().journal);
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns an exact release from one selected Journal worldview. */
|
||||
@Override
|
||||
public Optional<ArtifactRelease> release(
|
||||
ArtifactCoordinate coordinate,
|
||||
JournalVersion journalVersion,
|
||||
SemanticVersion releaseVersion) {
|
||||
Objects.requireNonNull(releaseVersion, "releaseVersion");
|
||||
return revision(coordinate, journalVersion).flatMap(journal -> journal.release(releaseVersion));
|
||||
}
|
||||
|
||||
/** Returns the exact UTF-8 text digest stored for a selected revision. */
|
||||
Optional<ContentDigest> revisionDigest(
|
||||
ArtifactCoordinate coordinate,
|
||||
JournalVersion journalVersion) {
|
||||
Objects.requireNonNull(coordinate, "coordinate");
|
||||
Objects.requireNonNull(journalVersion, "journalVersion");
|
||||
synchronized (stateLock) {
|
||||
NavigableMap<JournalVersion, StoredRevision> chain = chains.get(coordinate);
|
||||
if (chain == null) return Optional.empty();
|
||||
StoredRevision stored = chain.get(journalVersion);
|
||||
return stored == null ? Optional.empty() : Optional.of(stored.digest);
|
||||
}
|
||||
}
|
||||
|
||||
private InvalidJournalException invalidPredecessor(
|
||||
String source,
|
||||
ArtifactCoordinate candidateCoordinate,
|
||||
ContentDigest predecessor) {
|
||||
ArtifactCoordinate owner = digestOwners.get(predecessor.value());
|
||||
if (owner == null) {
|
||||
return chainError(source,
|
||||
"PREVIOUS_JOURNAL_DIGEST names a missing predecessor: " + predecessor);
|
||||
}
|
||||
if (!owner.equals(candidateCoordinate)) {
|
||||
return chainError(source,
|
||||
"PREVIOUS_JOURNAL_DIGEST belongs to artifact " + owner
|
||||
+ ", not " + candidateCoordinate);
|
||||
}
|
||||
return chainError(source,
|
||||
"PREVIOUS_JOURNAL_DIGEST names a non-head predecessor and would fork the chain for "
|
||||
+ candidateCoordinate);
|
||||
}
|
||||
|
||||
private static ContentDigest exactTextDigest(String completeJournalText) {
|
||||
Objects.requireNonNull(completeJournalText, "completeJournalText");
|
||||
try {
|
||||
MessageDigest sha256 = MessageDigest.getInstance("SHA-256");
|
||||
byte[] digest = sha256.digest(completeJournalText.getBytes(StandardCharsets.UTF_8));
|
||||
return ContentDigest.sha256(HexFormat.of().formatHex(digest));
|
||||
} catch (NoSuchAlgorithmException impossible) {
|
||||
throw new IllegalStateException("Java runtime does not provide SHA-256", impossible);
|
||||
}
|
||||
}
|
||||
|
||||
private static String sourceName(String source) {
|
||||
return source == null || source.isBlank() ? DEFAULT_SOURCE : source;
|
||||
}
|
||||
|
||||
private static InvalidJournalException chainError(String source, String reason) {
|
||||
return new InvalidJournalException(source, 0, reason);
|
||||
}
|
||||
|
||||
private record StoredRevision(Journal journal, String completeText, ContentDigest digest) {
|
||||
private StoredRevision {
|
||||
Objects.requireNonNull(journal, "journal");
|
||||
Objects.requireNonNull(completeText, "completeText");
|
||||
Objects.requireNonNull(digest, "digest");
|
||||
}
|
||||
}
|
||||
}
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
package com.r35157.nenjim.service.journal.impl.ref;
|
||||
|
||||
import com.r35157.nenjim.service.journal.JournalService;
|
||||
import com.r35157.nenjim.service.journal.JournalServiceManager;
|
||||
import com.r35157.nenjim.service.journal.model.Journal;
|
||||
import com.r35157.nenjim.service.journal.valuetypes.ArtifactCoordinate;
|
||||
import com.r35157.nenjim.service.journal.valuetypes.JournalVersion;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/** Filesystem-backed reference lifecycle manager for a read-only Journal service. */
|
||||
public final class JournalServiceManagerImpl implements JournalServiceManager {
|
||||
private static final String JOURNAL_SUFFIX = ".journal";
|
||||
private static final String EXAMPLE_SUFFIX = ".journal.example";
|
||||
|
||||
private final Path journalRoot;
|
||||
private JournalServiceImpl activeService;
|
||||
|
||||
public JournalServiceManagerImpl(Path journalRoot) {
|
||||
this.journalRoot = Objects.requireNonNull(journalRoot, "journalRoot").toAbsolutePath().normalize();
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void start() throws IOException {
|
||||
if (activeService != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
JournalServiceImpl candidate = new JournalServiceImpl();
|
||||
loadAll(candidate);
|
||||
activeService = candidate;
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void stop() {
|
||||
activeService = null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Path journalRoot() {
|
||||
return journalRoot;
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized JournalService journalService() {
|
||||
return requireActiveService();
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void refresh() throws IOException {
|
||||
loadAll(requireActiveService());
|
||||
}
|
||||
|
||||
private JournalServiceImpl requireActiveService() {
|
||||
if (activeService == null) {
|
||||
throw new IllegalStateException("Journal service manager has not been started");
|
||||
}
|
||||
return activeService;
|
||||
}
|
||||
|
||||
private void loadAll(JournalServiceImpl service) throws IOException {
|
||||
validateRoot();
|
||||
for (Path artifactDirectory : listEntries(journalRoot)) {
|
||||
if (!Files.isDirectory(artifactDirectory)) {
|
||||
throw new IOException("Unexpected entry in Journal root; expected an artifact directory: "
|
||||
+ artifactDirectory);
|
||||
}
|
||||
loadArtifactDirectory(service, artifactDirectory);
|
||||
}
|
||||
}
|
||||
|
||||
private void validateRoot() throws IOException {
|
||||
if (!Files.exists(journalRoot)) {
|
||||
throw new IOException("Configured Journal root does not exist: " + journalRoot);
|
||||
}
|
||||
if (!Files.isDirectory(journalRoot)) {
|
||||
throw new IOException("Configured Journal root is not a directory: " + journalRoot);
|
||||
}
|
||||
if (!Files.isReadable(journalRoot)) {
|
||||
throw new IOException("Configured Journal root is not readable: " + journalRoot);
|
||||
}
|
||||
}
|
||||
|
||||
private static void loadArtifactDirectory(
|
||||
JournalServiceImpl service,
|
||||
Path directory) throws IOException {
|
||||
String directoryName = directory.getFileName().toString();
|
||||
ArtifactCoordinate expectedCoordinate;
|
||||
try {
|
||||
expectedCoordinate = ArtifactCoordinate.parse(directoryName);
|
||||
} catch (IllegalArgumentException exception) {
|
||||
throw new IOException("Invalid artifact-coordinate directory name: " + directory, exception);
|
||||
}
|
||||
if (!Files.isReadable(directory)) {
|
||||
throw new IOException("Artifact Journal directory is not readable: " + directory);
|
||||
}
|
||||
|
||||
ArrayList<JournalFile> runtimeFiles = new ArrayList<>();
|
||||
for (Path entry : listEntries(directory)) {
|
||||
if (!Files.isRegularFile(entry)) {
|
||||
throw new IOException("Unexpected entry in artifact Journal directory: " + entry);
|
||||
}
|
||||
String filename = entry.getFileName().toString();
|
||||
if (filename.endsWith(EXAMPLE_SUFFIX)) {
|
||||
continue;
|
||||
}
|
||||
if (!filename.endsWith(JOURNAL_SUFFIX)) {
|
||||
throw new IOException("Unexpected file in artifact Journal directory: " + entry);
|
||||
}
|
||||
if (!Files.isReadable(entry)) {
|
||||
throw new IOException("Journal file is not readable: " + entry);
|
||||
}
|
||||
|
||||
String versionText = filename.substring(0, filename.length() - JOURNAL_SUFFIX.length());
|
||||
JournalVersion expectedVersion;
|
||||
try {
|
||||
expectedVersion = JournalVersion.parse(versionText);
|
||||
} catch (IllegalArgumentException exception) {
|
||||
throw new IOException("Invalid Journal filename; expected <journal-version>.journal: " + entry,
|
||||
exception);
|
||||
}
|
||||
runtimeFiles.add(new JournalFile(entry, expectedVersion));
|
||||
}
|
||||
|
||||
runtimeFiles.sort(Comparator.comparing(JournalFile::journalVersion));
|
||||
for (JournalFile runtimeFile : runtimeFiles) {
|
||||
String text = Files.readString(runtimeFile.path, StandardCharsets.UTF_8);
|
||||
String source = runtimeFile.path.toString();
|
||||
Journal parsed = service.parse(text, source);
|
||||
if (!parsed.metadata().coordinate().equals(expectedCoordinate)) {
|
||||
throw new IOException("Journal metadata coordinate " + parsed.metadata().coordinate()
|
||||
+ " does not match directory " + expectedCoordinate + ": " + runtimeFile.path);
|
||||
}
|
||||
if (!parsed.journalVersion().equals(runtimeFile.journalVersion)) {
|
||||
throw new IOException("JOURNAL_VERSION " + parsed.journalVersion()
|
||||
+ " does not match filename version " + runtimeFile.journalVersion + ": " + runtimeFile.path);
|
||||
}
|
||||
service.ingest(text, source);
|
||||
}
|
||||
}
|
||||
|
||||
private static List<Path> listEntries(Path directory) throws IOException {
|
||||
try (Stream<Path> entries = Files.list(directory)) {
|
||||
return entries.sorted(Comparator.comparing(path -> path.getFileName().toString())).toList();
|
||||
} catch (SecurityException exception) {
|
||||
throw new IOException("Cannot read Journal directory: " + directory, exception);
|
||||
}
|
||||
}
|
||||
|
||||
private record JournalFile(Path path, JournalVersion journalVersion) {
|
||||
private JournalFile {
|
||||
Objects.requireNonNull(path, "path");
|
||||
Objects.requireNonNull(journalVersion, "journalVersion");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,683 @@
|
||||
package com.r35157.nenjim.service.journal.impl.ref;
|
||||
|
||||
import com.r35157.libs.valuetypes.basic.SemanticVersion;
|
||||
import com.r35157.nenjim.service.journal.exception.InvalidJournalException;
|
||||
import com.r35157.nenjim.service.journal.model.ArtifactDependency;
|
||||
import com.r35157.nenjim.service.journal.model.ArtifactRelease;
|
||||
import com.r35157.nenjim.service.journal.model.Journal;
|
||||
import com.r35157.nenjim.service.journal.model.JournalMetadata;
|
||||
import com.r35157.nenjim.service.journal.model.JournalPolicy;
|
||||
import com.r35157.nenjim.service.journal.model.PolicyRule;
|
||||
import com.r35157.nenjim.service.journal.valuetypes.ArtifactCoordinate;
|
||||
import com.r35157.nenjim.service.journal.valuetypes.ContentDigest;
|
||||
import com.r35157.nenjim.service.journal.valuetypes.DependencyTarget;
|
||||
import com.r35157.nenjim.service.journal.valuetypes.JournalVersion;
|
||||
import com.r35157.nenjim.service.journal.valuetypes.VersionExpression;
|
||||
import com.r35157.nenjim.service.journal.valuetypes.VersionExpressionSet;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/** Strict line-oriented parser for Journal format version 1. */
|
||||
final class JournalTextParser {
|
||||
private static final Pattern FIELD_NAME = Pattern.compile("[A-Z][A-Z0-9_]*");
|
||||
private static final Pattern RELEASE_SECTION = Pattern.compile("\\[RELEASE ([^]]+)]");
|
||||
private static final String VERSION_NUMBER = "(?:0|[1-9]\\d*)";
|
||||
private static final Pattern VERSION_ENDPOINT = Pattern.compile(
|
||||
"(" + VERSION_NUMBER + ")\\.(" + VERSION_NUMBER + ")(?:\\.(" + VERSION_NUMBER + "))?");
|
||||
private static final Pattern EXACT_VERSION = Pattern.compile(
|
||||
VERSION_NUMBER + "\\." + VERSION_NUMBER + "\\." + VERSION_NUMBER);
|
||||
|
||||
Journal parse(String completeJournalText) {
|
||||
return parse(completeJournalText, "<journal>");
|
||||
}
|
||||
|
||||
Journal parse(String completeJournalText, String logicalSourceName) {
|
||||
if (completeJournalText == null) {
|
||||
throw new InvalidJournalException(logicalSourceName, 0, "Journal text cannot be null");
|
||||
}
|
||||
if (completeJournalText.isBlank()) {
|
||||
throw new InvalidJournalException(logicalSourceName, 0, "Journal text cannot be empty or blank");
|
||||
}
|
||||
String source = logicalSourceName == null || logicalSourceName.isBlank()
|
||||
? "<journal>" : logicalSourceName;
|
||||
Cursor cursor = new Cursor(tokenize(completeJournalText), source);
|
||||
if (!cursor.hasNext()) {
|
||||
throw new InvalidJournalException(source, 0, "Journal contains no configuration entries");
|
||||
}
|
||||
|
||||
Line formatLine = cursor.next();
|
||||
Field format = parseField(formatLine, source);
|
||||
if (!"FORMAT_VERSION".equals(format.name)) {
|
||||
throw invalid(source, formatLine,
|
||||
"FORMAT_VERSION=1 must be the first actual configuration entry");
|
||||
}
|
||||
if (!"1".equals(format.value)) {
|
||||
throw invalid(source, formatLine,
|
||||
"Unsupported FORMAT_VERSION '" + format.value + "'; only version 1 is supported");
|
||||
}
|
||||
|
||||
Line versionLine = cursor.requireNext("Missing required JOURNAL_VERSION");
|
||||
Field versionField = parseField(versionLine, source);
|
||||
if (!"JOURNAL_VERSION".equals(versionField.name)) {
|
||||
throw invalid(source, versionLine, "Expected JOURNAL_VERSION after FORMAT_VERSION");
|
||||
}
|
||||
JournalVersion journalVersion = parseJournalVersion(versionField.value, versionLine, source);
|
||||
|
||||
Optional<ContentDigest> previousDigest = Optional.empty();
|
||||
if (cursor.hasNext() && cursor.peek().content.startsWith("PREVIOUS_JOURNAL_DIGEST")) {
|
||||
Line previousLine = cursor.next();
|
||||
Field previousField = parseField(previousLine, source);
|
||||
if (!"PREVIOUS_JOURNAL_DIGEST".equals(previousField.name)) {
|
||||
throw invalid(source, previousLine, "Malformed PREVIOUS_JOURNAL_DIGEST entry");
|
||||
}
|
||||
ContentDigest digest = parseContentDigest(previousField.value, previousLine, source);
|
||||
if (!ContentDigest.SHA256.equals(digest.scheme())) {
|
||||
throw invalid(source, previousLine, "PREVIOUS_JOURNAL_DIGEST must use sha256");
|
||||
}
|
||||
previousDigest = Optional.of(digest);
|
||||
}
|
||||
|
||||
Line metadataHeader = cursor.requireNext("Missing required [METADATA] section");
|
||||
if (!"[METADATA]".equals(metadataHeader.content)) {
|
||||
throw invalid(source, metadataHeader, "Expected [METADATA] section");
|
||||
}
|
||||
JournalMetadata metadata = parseMetadata(cursor, metadataHeader, source);
|
||||
|
||||
ParsedPolicy parsedPolicy = new ParsedPolicy(JournalPolicy.empty(), List.of());
|
||||
if (cursor.hasNext() && "[POLICY]".equals(cursor.peek().content)) {
|
||||
Line policyHeader = cursor.next();
|
||||
parsedPolicy = parsePolicy(cursor, policyHeader, source);
|
||||
}
|
||||
|
||||
if (!cursor.hasNext()) {
|
||||
throw invalid(source, metadataHeader, "A Journal requires at least one [RELEASE <version>] section");
|
||||
}
|
||||
|
||||
LinkedHashMap<SemanticVersion, ArtifactRelease> releases = new LinkedHashMap<>();
|
||||
while (cursor.hasNext()) {
|
||||
Line releaseHeader = cursor.next();
|
||||
Matcher sectionMatcher = RELEASE_SECTION.matcher(releaseHeader.content);
|
||||
if (!sectionMatcher.matches()) {
|
||||
throw invalid(source, releaseHeader,
|
||||
"Expected [RELEASE <major.minor.patch>] section, found '" + releaseHeader.content + "'");
|
||||
}
|
||||
SemanticVersion releaseVersion = parseExactVersion(sectionMatcher.group(1), releaseHeader, source);
|
||||
if (releases.containsKey(releaseVersion)) {
|
||||
throw invalid(source, releaseHeader, "Duplicate release section for " + releaseVersion);
|
||||
}
|
||||
ArtifactRelease release = parseRelease(cursor, releaseHeader, releaseVersion, source);
|
||||
releases.put(releaseVersion, release);
|
||||
}
|
||||
|
||||
validateRecommendations(parsedPolicy.recommendedRules, releases, source);
|
||||
try {
|
||||
return new Journal(1, journalVersion, previousDigest, metadata, parsedPolicy.policy, releases);
|
||||
} catch (IllegalArgumentException exception) {
|
||||
throw invalid(source, metadataHeader, exception.getMessage(), exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static JournalMetadata parseMetadata(Cursor cursor, Line header, String source) {
|
||||
Map<String, FieldAtLine> values = new LinkedHashMap<>();
|
||||
Set<String> allowed = Set.of("GROUP", "MODULE", "ARTIFACT", "DESCRIPTION", "INFORMATION_URI");
|
||||
while (cursor.hasNext() && !isSection(cursor.peek())) {
|
||||
Line line = cursor.next();
|
||||
Field field = parseField(line, source);
|
||||
if (!allowed.contains(field.name)) {
|
||||
throw invalid(source, line, "Unknown or misplaced [METADATA] field '" + field.name + "'");
|
||||
}
|
||||
if (values.putIfAbsent(field.name, new FieldAtLine(field.value, line)) != null) {
|
||||
throw invalid(source, line, "Duplicate [METADATA] field '" + field.name + "'");
|
||||
}
|
||||
}
|
||||
|
||||
FieldAtLine group = requireField(values, "GROUP", header, source);
|
||||
FieldAtLine module = requireField(values, "MODULE", header, source);
|
||||
FieldAtLine artifact = requireField(values, "ARTIFACT", header, source);
|
||||
FieldAtLine description = requireField(values, "DESCRIPTION", header, source);
|
||||
|
||||
requirePlainValue(group, "GROUP", source);
|
||||
requirePlainValue(module, "MODULE", source);
|
||||
requirePlainValue(artifact, "ARTIFACT", source);
|
||||
String decodedDescription = parseQuoted(description.value, "DESCRIPTION", description.line, source);
|
||||
|
||||
ArtifactCoordinate coordinate;
|
||||
try {
|
||||
coordinate = new ArtifactCoordinate(group.value, module.value, artifact.value);
|
||||
} catch (IllegalArgumentException exception) {
|
||||
throw invalid(source, group.line, exception.getMessage(), exception);
|
||||
}
|
||||
|
||||
Optional<URI> informationUri = Optional.empty();
|
||||
FieldAtLine information = values.get("INFORMATION_URI");
|
||||
if (information != null) {
|
||||
String decoded = parseQuoted(information.value, "INFORMATION_URI", information.line, source);
|
||||
try {
|
||||
URI uri = new URI(decoded);
|
||||
if (!uri.isAbsolute()) {
|
||||
throw invalid(source, information.line,
|
||||
"INFORMATION_URI must be an absolute URI: '" + decoded + "'");
|
||||
}
|
||||
informationUri = Optional.of(uri);
|
||||
} catch (URISyntaxException exception) {
|
||||
throw invalid(source, information.line,
|
||||
"INFORMATION_URI is not a syntactically valid URI: '" + decoded + "'", exception);
|
||||
}
|
||||
}
|
||||
return new JournalMetadata(coordinate, decodedDescription, informationUri);
|
||||
}
|
||||
|
||||
private static ParsedPolicy parsePolicy(Cursor cursor, Line header, String source) {
|
||||
List<PolicyRule> recommended = new ArrayList<>();
|
||||
List<RuleAtLine> recommendedAtLines = new ArrayList<>();
|
||||
List<PolicyRule> discouraged = new ArrayList<>();
|
||||
List<PolicyRule> blacklist = new ArrayList<>();
|
||||
|
||||
while (cursor.hasNext() && !isSection(cursor.peek())) {
|
||||
Line line = cursor.next();
|
||||
Field field = parseField(line, source);
|
||||
switch (field.name) {
|
||||
case "RECOMMENDED" -> {
|
||||
PolicyRule rule = parseRule(field.value, line, source, true);
|
||||
recommended.add(rule);
|
||||
recommendedAtLines.add(new RuleAtLine(rule, line));
|
||||
}
|
||||
case "DISCOURAGED" -> discouraged.add(parseRule(field.value, line, source, false));
|
||||
case "BLACKLIST" -> blacklist.add(parseRule(field.value, line, source, false));
|
||||
default -> throw invalid(source, line,
|
||||
"Unknown or misplaced [POLICY] field '" + field.name + "'");
|
||||
}
|
||||
}
|
||||
try {
|
||||
return new ParsedPolicy(
|
||||
new JournalPolicy(recommended, discouraged, blacklist),
|
||||
List.copyOf(recommendedAtLines));
|
||||
} catch (IllegalArgumentException exception) {
|
||||
throw invalid(source, header, exception.getMessage(), exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static ArtifactRelease parseRelease(
|
||||
Cursor cursor,
|
||||
Line header,
|
||||
SemanticVersion version,
|
||||
String source) {
|
||||
FieldAtLine license = null;
|
||||
FieldAtLine publishedAt = null;
|
||||
FieldAtLine contentSize = null;
|
||||
LinkedHashMap<String, ContentDigest> digests = new LinkedHashMap<>();
|
||||
LinkedHashMap<DependencyTarget, DependencyBuilder> dependencyBuilders = new LinkedHashMap<>();
|
||||
|
||||
while (cursor.hasNext() && !isSection(cursor.peek())) {
|
||||
Line line = cursor.next();
|
||||
Field field = parseField(line, source);
|
||||
switch (field.name) {
|
||||
case "LICENSE" -> {
|
||||
if (license != null) throw invalid(source, line, "Duplicate LICENSE field");
|
||||
license = new FieldAtLine(field.value, line);
|
||||
}
|
||||
case "PUBLISHED_AT" -> {
|
||||
if (publishedAt != null) throw invalid(source, line, "Duplicate PUBLISHED_AT field");
|
||||
publishedAt = new FieldAtLine(field.value, line);
|
||||
}
|
||||
case "CONTENT_DIGEST" -> {
|
||||
ContentDigest digest = parseContentDigest(field.value, line, source);
|
||||
if (digests.putIfAbsent(digest.scheme(), digest) != null) {
|
||||
throw invalid(source, line,
|
||||
"Duplicate CONTENT_DIGEST scheme '" + digest.scheme() + "'");
|
||||
}
|
||||
}
|
||||
case "CONTENT_SIZE" -> {
|
||||
if (contentSize != null) throw invalid(source, line, "Duplicate CONTENT_SIZE field");
|
||||
contentSize = new FieldAtLine(field.value, line);
|
||||
}
|
||||
case "DEPENDS_ON", "EXCLUDES", "PREFERRED" -> parseDependencyRule(
|
||||
field, line, source, dependencyBuilders);
|
||||
default -> throw invalid(source, line,
|
||||
"Unknown or misplaced release field '" + field.name + "'");
|
||||
}
|
||||
}
|
||||
|
||||
if (license == null) throw invalid(source, header, "Release " + version + " is missing LICENSE");
|
||||
if (publishedAt == null) throw invalid(source, header, "Release " + version + " is missing PUBLISHED_AT");
|
||||
if (digests.isEmpty()) throw invalid(source, header, "Release " + version + " requires CONTENT_DIGEST");
|
||||
if (contentSize == null) throw invalid(source, header, "Release " + version + " is missing CONTENT_SIZE");
|
||||
|
||||
String decodedLicense = parseQuoted(license.value, "LICENSE", license.line, source);
|
||||
Instant publicationTime = parseJournalVersion(publishedAt.value, publishedAt.line, source).instant();
|
||||
long size;
|
||||
try {
|
||||
size = Long.parseLong(contentSize.value);
|
||||
if (size < 0) throw new NumberFormatException("negative");
|
||||
} catch (NumberFormatException exception) {
|
||||
throw invalid(source, contentSize.line,
|
||||
"CONTENT_SIZE must be a non-negative long: '" + contentSize.value + "'", exception);
|
||||
}
|
||||
|
||||
List<ArtifactDependency> dependencies = new ArrayList<>();
|
||||
for (DependencyBuilder builder : dependencyBuilders.values()) {
|
||||
if (builder.dependsOn == null) {
|
||||
throw invalid(source, builder.firstLine,
|
||||
builder.target + " has EXCLUDES or PREFERRED without DEPENDS_ON");
|
||||
}
|
||||
try {
|
||||
dependencies.add(new ArtifactDependency(
|
||||
builder.target, builder.dependsOn, builder.exclusions, builder.preferences));
|
||||
} catch (IllegalArgumentException exception) {
|
||||
throw invalid(source, builder.firstLine, exception.getMessage(), exception);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return new ArtifactRelease(
|
||||
version,
|
||||
decodedLicense,
|
||||
publicationTime,
|
||||
List.copyOf(digests.values()),
|
||||
size,
|
||||
dependencies);
|
||||
} catch (IllegalArgumentException exception) {
|
||||
throw invalid(source, header, exception.getMessage(), exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static void parseDependencyRule(
|
||||
Field field,
|
||||
Line line,
|
||||
String source,
|
||||
Map<DependencyTarget, DependencyBuilder> builders) {
|
||||
int assignment = field.value.indexOf('=');
|
||||
if (assignment <= 0 || assignment == field.value.length() - 1) {
|
||||
throw invalid(source, line,
|
||||
field.name + " must have the form <scheme>:<coordinate>=<version-expression-list>");
|
||||
}
|
||||
String targetText = field.value.substring(0, assignment).trim();
|
||||
String ruleText = field.value.substring(assignment + 1).trim();
|
||||
DependencyTarget target = parseDependencyTarget(targetText, line, source);
|
||||
DependencyBuilder builder = builders.computeIfAbsent(
|
||||
target, ignored -> new DependencyBuilder(target, line));
|
||||
|
||||
if ("DEPENDS_ON".equals(field.name)) {
|
||||
if (builder.dependsOn != null) {
|
||||
throw invalid(source, line, "Duplicate DEPENDS_ON for " + target);
|
||||
}
|
||||
builder.dependsOn = parseVersionExpressionSet(ruleText, line, source);
|
||||
return;
|
||||
}
|
||||
|
||||
PolicyRule rule = parseRule(ruleText, line, source, false);
|
||||
if ("EXCLUDES".equals(field.name)) builder.exclusions.add(rule);
|
||||
else builder.preferences.add(rule);
|
||||
}
|
||||
|
||||
private static DependencyTarget parseDependencyTarget(String text, Line line, String source) {
|
||||
int separator = text.indexOf(':');
|
||||
if (separator <= 0 || separator == text.length() - 1) {
|
||||
throw invalid(source, line,
|
||||
"Dependency target must have the form <scheme>:<scheme-specific-coordinate>");
|
||||
}
|
||||
String scheme = text.substring(0, separator);
|
||||
if (!DependencyTarget.NENJIM_SCHEME.equals(scheme)) {
|
||||
throw invalid(source, line,
|
||||
"Unsupported dependency scheme '" + scheme + "'; format version 1 supports only nenjim");
|
||||
}
|
||||
try {
|
||||
return DependencyTarget.nenjim(ArtifactCoordinate.parse(text.substring(separator + 1)));
|
||||
} catch (IllegalArgumentException exception) {
|
||||
throw invalid(source, line, exception.getMessage(), exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static PolicyRule parseRule(String text, Line line, String source, boolean exactOnly) {
|
||||
DescribedValue described = splitDescription(text, line, source);
|
||||
List<String> expressionTexts = splitExpressionList(described.value, line, source);
|
||||
if (exactOnly && expressionTexts.size() != 1) {
|
||||
throw invalid(source, line, "RECOMMENDED must identify exactly one exact release per line");
|
||||
}
|
||||
if (exactOnly && expressionTexts.getFirst().contains("-->")) {
|
||||
throw invalid(source, line, "RECOMMENDED does not accept range syntax");
|
||||
}
|
||||
List<VersionExpression> expressions = new ArrayList<>();
|
||||
for (String expressionText : expressionTexts) {
|
||||
expressions.add(parseVersionExpression(expressionText, line, source));
|
||||
}
|
||||
VersionExpressionSet set = new VersionExpressionSet(expressions);
|
||||
if (exactOnly && (set.expressions().size() != 1 || !set.expressions().getFirst().isExact())) {
|
||||
throw invalid(source, line, "RECOMMENDED must identify one exact major.minor.patch release");
|
||||
}
|
||||
return new PolicyRule(set, described.description);
|
||||
}
|
||||
|
||||
private static VersionExpressionSet parseVersionExpressionSet(String text, Line line, String source) {
|
||||
List<VersionExpression> expressions = new ArrayList<>();
|
||||
for (String expressionText : splitExpressionList(text, line, source)) {
|
||||
expressions.add(parseVersionExpression(expressionText, line, source));
|
||||
}
|
||||
return new VersionExpressionSet(expressions);
|
||||
}
|
||||
|
||||
private static List<String> splitExpressionList(String text, Line line, String source) {
|
||||
if (text.isBlank()) throw invalid(source, line, "Version expression list cannot be empty");
|
||||
String[] elements = text.split(",", -1);
|
||||
List<String> result = new ArrayList<>(elements.length);
|
||||
for (String element : elements) {
|
||||
String trimmed = element.trim();
|
||||
if (trimmed.isEmpty()) throw invalid(source, line, "Version expression list contains an empty element");
|
||||
result.add(trimmed);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static VersionExpression parseVersionExpression(String text, Line line, String source) {
|
||||
if (EXACT_VERSION.matcher(text).matches()) {
|
||||
return VersionExpression.exact(parseExactVersion(text, line, source));
|
||||
}
|
||||
|
||||
if (text.startsWith("[") && text.endsWith("]") && !text.contains("-->")) {
|
||||
Endpoint endpoint = parseEndpoint(text.substring(1, text.length() - 1), line, source);
|
||||
if (endpoint.hasPatch()) {
|
||||
return VersionExpression.exact(endpoint.toSemanticVersion());
|
||||
}
|
||||
return VersionExpression.minorSeries(endpoint.major, endpoint.minor);
|
||||
}
|
||||
|
||||
if ((text.startsWith("[") || text.startsWith("(")) && text.contains("-->")) {
|
||||
char lowerDelimiter = text.charAt(0);
|
||||
int arrow = text.indexOf("-->");
|
||||
if (text.indexOf("-->", arrow + 3) >= 0) {
|
||||
throw invalid(source, line, "Version range contains more than one '-->' separator");
|
||||
}
|
||||
Endpoint lowerEndpoint = parseEndpoint(text.substring(1, arrow).trim(), line, source);
|
||||
VersionExpression.Boundary lower = lowerEndpoint.boundary();
|
||||
if (lowerDelimiter == '(') {
|
||||
lower = lowerEndpoint.hasPatch() ? lower.nextPatch() : lower.nextMinor();
|
||||
}
|
||||
|
||||
String upperText = text.substring(arrow + 3).trim();
|
||||
VersionExpression.Boundary upper;
|
||||
if (upperText.isEmpty()) {
|
||||
upper = lowerEndpoint.boundary().nextMajor();
|
||||
} else {
|
||||
char upperDelimiter = upperText.charAt(upperText.length() - 1);
|
||||
if (upperDelimiter != ']' && upperDelimiter != ')') {
|
||||
throw invalid(source, line, "A bounded version range must end with ']' or ')'");
|
||||
}
|
||||
Endpoint upperEndpoint = parseEndpoint(
|
||||
upperText.substring(0, upperText.length() - 1).trim(), line, source);
|
||||
upper = upperEndpoint.boundary();
|
||||
if (upperDelimiter == ']') {
|
||||
upper = upperEndpoint.hasPatch() ? upper.nextPatch() : upper.nextMinor();
|
||||
}
|
||||
}
|
||||
try {
|
||||
return new VersionExpression(lower, upper);
|
||||
} catch (IllegalArgumentException exception) {
|
||||
throw invalid(source, line, exception.getMessage(), exception);
|
||||
}
|
||||
}
|
||||
|
||||
throw invalid(source, line,
|
||||
"Invalid version expression '" + text
|
||||
+ "'; use an exact major.minor.patch value or a Nenjim bracket range");
|
||||
}
|
||||
|
||||
private static Endpoint parseEndpoint(String text, Line line, String source) {
|
||||
Matcher matcher = VERSION_ENDPOINT.matcher(text);
|
||||
if (!matcher.matches()) {
|
||||
throw invalid(source, line,
|
||||
"Version-expression endpoints require major.minor with no internal whitespace: '" + text + "'");
|
||||
}
|
||||
try {
|
||||
int major = Integer.parseInt(matcher.group(1));
|
||||
int minor = Integer.parseInt(matcher.group(2));
|
||||
Integer patch = matcher.group(3) == null ? null : Integer.parseInt(matcher.group(3));
|
||||
return new Endpoint(major, minor, patch);
|
||||
} catch (NumberFormatException exception) {
|
||||
throw invalid(source, line, "Version element is outside the supported integer range: '" + text + "'", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static SemanticVersion parseExactVersion(String text, Line line, String source) {
|
||||
if (!EXACT_VERSION.matcher(text).matches()) {
|
||||
throw invalid(source, line,
|
||||
"Artifact release version must contain exact major.minor.patch without prerelease or build metadata: '"
|
||||
+ text + "'");
|
||||
}
|
||||
Endpoint endpoint = parseEndpoint(text, line, source);
|
||||
return endpoint.toSemanticVersion();
|
||||
}
|
||||
|
||||
private static DescribedValue splitDescription(String text, Line line, String source) {
|
||||
int separator = text.indexOf(':');
|
||||
if (separator < 0) return new DescribedValue(text.trim(), Optional.empty());
|
||||
|
||||
String value = text.substring(0, separator).trim();
|
||||
if (value.isEmpty()) throw invalid(source, line, "Rule value cannot be empty");
|
||||
String descriptionText = text.substring(separator + 1).trim();
|
||||
String description = parseQuoted(descriptionText, "rule description", line, source);
|
||||
return new DescribedValue(value, Optional.of(description));
|
||||
}
|
||||
|
||||
private static String parseQuoted(String text, String fieldName, Line line, String source) {
|
||||
String value = text.trim();
|
||||
if (value.length() < 2 || value.charAt(0) != '\'' || value.charAt(value.length() - 1) != '\'') {
|
||||
throw invalid(source, line, fieldName + " must be enclosed in single quotes");
|
||||
}
|
||||
StringBuilder decoded = new StringBuilder();
|
||||
for (int index = 1; index < value.length() - 1; index++) {
|
||||
char current = value.charAt(index);
|
||||
if (current == '\\') {
|
||||
if (index + 1 >= value.length() - 1) {
|
||||
throw invalid(source, line, fieldName + " ends with an incomplete escape");
|
||||
}
|
||||
char escaped = value.charAt(++index);
|
||||
if (escaped != '\'' && escaped != '\\') {
|
||||
throw invalid(source, line,
|
||||
fieldName + " supports only \\' and \\\\ escapes");
|
||||
}
|
||||
decoded.append(escaped);
|
||||
} else if (current == '\'') {
|
||||
throw invalid(source, line, fieldName + " contains an unescaped single quote");
|
||||
} else {
|
||||
decoded.append(current);
|
||||
}
|
||||
}
|
||||
if (decoded.toString().isBlank()) {
|
||||
throw invalid(source, line, fieldName + " cannot be empty or blank");
|
||||
}
|
||||
return decoded.toString();
|
||||
}
|
||||
|
||||
private static ContentDigest parseContentDigest(String text, Line line, String source) {
|
||||
try {
|
||||
return ContentDigest.parse(text);
|
||||
} catch (IllegalArgumentException exception) {
|
||||
throw invalid(source, line, exception.getMessage(), exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static JournalVersion parseJournalVersion(String text, Line line, String source) {
|
||||
try {
|
||||
return JournalVersion.parse(text);
|
||||
} catch (IllegalArgumentException exception) {
|
||||
throw invalid(source, line, exception.getMessage(), exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateRecommendations(
|
||||
List<RuleAtLine> recommendedRules,
|
||||
Map<SemanticVersion, ArtifactRelease> releases,
|
||||
String source) {
|
||||
for (RuleAtLine ruleAtLine : recommendedRules) {
|
||||
SemanticVersion version = ruleAtLine.rule.versions()
|
||||
.expressions().getFirst().exactVersion().orElseThrow();
|
||||
if (!releases.containsKey(version)) {
|
||||
throw invalid(source, ruleAtLine.line,
|
||||
"RECOMMENDED release " + version + " does not exist in the same Journal snapshot");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Field parseField(Line line, String source) {
|
||||
int separator = line.content.indexOf('=');
|
||||
if (separator <= 0) {
|
||||
throw invalid(source, line, "Expected KEY=value configuration entry");
|
||||
}
|
||||
String name = line.content.substring(0, separator).trim();
|
||||
String value = line.content.substring(separator + 1).trim();
|
||||
if (!FIELD_NAME.matcher(name).matches()) {
|
||||
throw invalid(source, line, "Malformed field name '" + name + "'");
|
||||
}
|
||||
if (value.isEmpty()) {
|
||||
throw invalid(source, line, "Field '" + name + "' cannot have an empty value");
|
||||
}
|
||||
return new Field(name, value);
|
||||
}
|
||||
|
||||
private static void requirePlainValue(FieldAtLine field, String name, String source) {
|
||||
if (field.value.isBlank()) throw invalid(source, field.line, name + " cannot be blank");
|
||||
if (field.value.startsWith("'") || field.value.endsWith("'")) {
|
||||
throw invalid(source, field.line, name + " must be an unquoted coordinate component");
|
||||
}
|
||||
}
|
||||
|
||||
private static FieldAtLine requireField(
|
||||
Map<String, FieldAtLine> values,
|
||||
String name,
|
||||
Line header,
|
||||
String source) {
|
||||
FieldAtLine value = values.get(name);
|
||||
if (value == null) throw invalid(source, header, "[METADATA] is missing required field '" + name + "'");
|
||||
return value;
|
||||
}
|
||||
|
||||
private static boolean isSection(Line line) {
|
||||
return line.content.startsWith("[");
|
||||
}
|
||||
|
||||
private static List<Line> tokenize(String text) {
|
||||
String[] physicalLines = text.split("\\R", -1);
|
||||
ArrayList<Line> lines = new ArrayList<>();
|
||||
for (int index = 0; index < physicalLines.length; index++) {
|
||||
String content = stripComment(physicalLines[index]).trim();
|
||||
if (!content.isEmpty()) lines.add(new Line(index + 1, content));
|
||||
}
|
||||
return List.copyOf(lines);
|
||||
}
|
||||
|
||||
private static String stripComment(String line) {
|
||||
StringBuilder content = new StringBuilder();
|
||||
boolean inQuote = false;
|
||||
boolean escaped = false;
|
||||
for (int index = 0; index < line.length(); index++) {
|
||||
char current = line.charAt(index);
|
||||
if (!inQuote && current == '#') break;
|
||||
content.append(current);
|
||||
if (inQuote && escaped) {
|
||||
escaped = false;
|
||||
} else if (inQuote && current == '\\') {
|
||||
escaped = true;
|
||||
} else if (current == '\'') {
|
||||
inQuote = !inQuote;
|
||||
}
|
||||
}
|
||||
return content.toString();
|
||||
}
|
||||
|
||||
private static InvalidJournalException invalid(String source, Line line, String reason) {
|
||||
return new InvalidJournalException(source, line.number, reason);
|
||||
}
|
||||
|
||||
private static InvalidJournalException invalid(
|
||||
String source,
|
||||
Line line,
|
||||
String reason,
|
||||
Throwable cause) {
|
||||
return new InvalidJournalException(source, line.number, reason, cause);
|
||||
}
|
||||
|
||||
private record Line(int number, String content) {
|
||||
}
|
||||
|
||||
private record Field(String name, String value) {
|
||||
}
|
||||
|
||||
private record FieldAtLine(String value, Line line) {
|
||||
}
|
||||
|
||||
private record DescribedValue(String value, Optional<String> description) {
|
||||
}
|
||||
|
||||
private record RuleAtLine(PolicyRule rule, Line line) {
|
||||
}
|
||||
|
||||
private record ParsedPolicy(JournalPolicy policy, List<RuleAtLine> recommendedRules) {
|
||||
}
|
||||
|
||||
private record Endpoint(int major, int minor, Integer patch) {
|
||||
boolean hasPatch() {
|
||||
return patch != null;
|
||||
}
|
||||
|
||||
VersionExpression.Boundary boundary() {
|
||||
return new VersionExpression.Boundary(major, minor, patch == null ? 0 : patch);
|
||||
}
|
||||
|
||||
SemanticVersion toSemanticVersion() {
|
||||
if (patch == null) throw new IllegalStateException("Endpoint is not exact");
|
||||
return new SemanticVersion(major, minor, patch);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class DependencyBuilder {
|
||||
private final DependencyTarget target;
|
||||
private final Line firstLine;
|
||||
private VersionExpressionSet dependsOn;
|
||||
private final List<PolicyRule> exclusions = new ArrayList<>();
|
||||
private final List<PolicyRule> preferences = new ArrayList<>();
|
||||
|
||||
private DependencyBuilder(DependencyTarget target, Line firstLine) {
|
||||
this.target = Objects.requireNonNull(target, "target");
|
||||
this.firstLine = Objects.requireNonNull(firstLine, "firstLine");
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Cursor {
|
||||
private final List<Line> lines;
|
||||
private final String source;
|
||||
private int index;
|
||||
|
||||
private Cursor(List<Line> lines, String source) {
|
||||
this.lines = lines;
|
||||
this.source = source;
|
||||
}
|
||||
|
||||
boolean hasNext() {
|
||||
return index < lines.size();
|
||||
}
|
||||
|
||||
Line peek() {
|
||||
return lines.get(index);
|
||||
}
|
||||
|
||||
Line next() {
|
||||
return lines.get(index++);
|
||||
}
|
||||
|
||||
Line requireNext(String reason) {
|
||||
if (!hasNext()) throw new InvalidJournalException(source, 0, reason);
|
||||
return next();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.r35157.nenjim.service.journal.model;
|
||||
|
||||
import com.r35157.nenjim.service.journal.valuetypes.DependencyTarget;
|
||||
import com.r35157.nenjim.service.journal.valuetypes.VersionExpressionSet;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/** The complete version contract for one release-to-artifact relationship. */
|
||||
public record ArtifactDependency(
|
||||
DependencyTarget target,
|
||||
VersionExpressionSet dependsOn,
|
||||
List<PolicyRule> exclusions,
|
||||
List<PolicyRule> preferences) {
|
||||
public ArtifactDependency {
|
||||
Objects.requireNonNull(target, "target");
|
||||
Objects.requireNonNull(dependsOn, "dependsOn");
|
||||
if (dependsOn.isEmpty()) {
|
||||
throw new IllegalArgumentException("DEPENDS_ON cannot be empty");
|
||||
}
|
||||
exclusions = copyRules(exclusions, "exclusions");
|
||||
preferences = copyRules(preferences, "preferences");
|
||||
|
||||
VersionExpressionSet excluded = union(exclusions);
|
||||
VersionExpressionSet preferred = union(preferences);
|
||||
VersionExpressionSet permitted = dependsOn.subtract(excluded);
|
||||
if (!permitted.containsAll(preferred)) {
|
||||
throw new IllegalArgumentException(
|
||||
"PREFERRED versions for " + target + " must be a subset of DEPENDS_ON minus EXCLUDES");
|
||||
}
|
||||
}
|
||||
|
||||
public VersionExpressionSet excludedVersions() {
|
||||
return union(exclusions);
|
||||
}
|
||||
|
||||
public VersionExpressionSet preferredVersions() {
|
||||
return union(preferences);
|
||||
}
|
||||
|
||||
public VersionExpressionSet permittedVersions() {
|
||||
return dependsOn.subtract(excludedVersions());
|
||||
}
|
||||
|
||||
private static List<PolicyRule> copyRules(List<PolicyRule> rules, String name) {
|
||||
Objects.requireNonNull(rules, name);
|
||||
rules.forEach(rule -> Objects.requireNonNull(rule, name + " rule"));
|
||||
return List.copyOf(rules);
|
||||
}
|
||||
|
||||
private static VersionExpressionSet union(List<PolicyRule> rules) {
|
||||
VersionExpressionSet result = VersionExpressionSet.empty();
|
||||
for (PolicyRule rule : rules) result = result.union(rule.versions());
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.r35157.nenjim.service.journal.model;
|
||||
|
||||
import com.r35157.libs.valuetypes.basic.SemanticVersion;
|
||||
import com.r35157.nenjim.service.journal.valuetypes.ContentDigest;
|
||||
import com.r35157.nenjim.service.journal.valuetypes.DependencyTarget;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
/** All knowledge about one exact artifact release in one Journal snapshot. */
|
||||
public record ArtifactRelease(
|
||||
SemanticVersion version,
|
||||
String license,
|
||||
Instant publishedAt,
|
||||
List<ContentDigest> contentDigests,
|
||||
long contentSize,
|
||||
List<ArtifactDependency> dependencies) {
|
||||
public ArtifactRelease {
|
||||
Objects.requireNonNull(version, "version");
|
||||
Objects.requireNonNull(license, "license");
|
||||
if (license.isBlank()) {
|
||||
throw new IllegalArgumentException("Release license cannot be blank");
|
||||
}
|
||||
Objects.requireNonNull(publishedAt, "publishedAt");
|
||||
if (publishedAt.getNano() % 1_000_000 != 0) {
|
||||
throw new IllegalArgumentException("Release publication time must have millisecond precision");
|
||||
}
|
||||
if (contentSize < 0) {
|
||||
throw new IllegalArgumentException("Release content size cannot be negative");
|
||||
}
|
||||
|
||||
contentDigests = copyDigests(contentDigests);
|
||||
dependencies = copyDependencies(dependencies);
|
||||
}
|
||||
|
||||
public Optional<ContentDigest> contentDigest(String scheme) {
|
||||
Objects.requireNonNull(scheme, "scheme");
|
||||
return contentDigests.stream().filter(digest -> digest.scheme().equals(scheme)).findFirst();
|
||||
}
|
||||
|
||||
public Optional<ArtifactDependency> dependency(DependencyTarget target) {
|
||||
Objects.requireNonNull(target, "target");
|
||||
return dependencies.stream().filter(dependency -> dependency.target().equals(target)).findFirst();
|
||||
}
|
||||
|
||||
private static List<ContentDigest> copyDigests(List<ContentDigest> digests) {
|
||||
Objects.requireNonNull(digests, "contentDigests");
|
||||
if (digests.isEmpty()) {
|
||||
throw new IllegalArgumentException("A release requires at least one content digest");
|
||||
}
|
||||
Set<String> schemes = new HashSet<>();
|
||||
for (ContentDigest digest : digests) {
|
||||
Objects.requireNonNull(digest, "content digest");
|
||||
if (!schemes.add(digest.scheme())) {
|
||||
throw new IllegalArgumentException("Duplicate content digest scheme: '" + digest.scheme() + "'");
|
||||
}
|
||||
}
|
||||
return List.copyOf(digests);
|
||||
}
|
||||
|
||||
private static List<ArtifactDependency> copyDependencies(List<ArtifactDependency> dependencies) {
|
||||
Objects.requireNonNull(dependencies, "dependencies");
|
||||
Set<DependencyTarget> targets = new HashSet<>();
|
||||
for (ArtifactDependency dependency : dependencies) {
|
||||
Objects.requireNonNull(dependency, "dependency");
|
||||
if (!targets.add(dependency.target())) {
|
||||
throw new IllegalArgumentException("Duplicate dependency target: '" + dependency.target() + "'");
|
||||
}
|
||||
}
|
||||
return List.copyOf(dependencies);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.r35157.nenjim.service.journal.model;
|
||||
|
||||
import com.r35157.libs.valuetypes.basic.SemanticVersion;
|
||||
import com.r35157.nenjim.service.journal.valuetypes.ContentDigest;
|
||||
import com.r35157.nenjim.service.journal.valuetypes.JournalVersion;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/** One immutable, complete Journal revision for exactly one artifact. */
|
||||
public record Journal(
|
||||
int formatVersion,
|
||||
JournalVersion journalVersion,
|
||||
Optional<ContentDigest> previousJournalDigest,
|
||||
JournalMetadata metadata,
|
||||
JournalPolicy policy,
|
||||
Map<SemanticVersion, ArtifactRelease> releases) {
|
||||
public Journal {
|
||||
if (formatVersion != 1) {
|
||||
throw new IllegalArgumentException("Unsupported Journal format version: " + formatVersion);
|
||||
}
|
||||
Objects.requireNonNull(journalVersion, "journalVersion");
|
||||
previousJournalDigest = Objects.requireNonNull(previousJournalDigest, "previousJournalDigest");
|
||||
previousJournalDigest.ifPresent(digest -> {
|
||||
if (!ContentDigest.SHA256.equals(digest.scheme())) {
|
||||
throw new IllegalArgumentException("Previous Journal digest must use SHA-256");
|
||||
}
|
||||
});
|
||||
Objects.requireNonNull(metadata, "metadata");
|
||||
Objects.requireNonNull(policy, "policy");
|
||||
Objects.requireNonNull(releases, "releases");
|
||||
if (releases.isEmpty()) {
|
||||
throw new IllegalArgumentException("A Journal requires at least one artifact release");
|
||||
}
|
||||
|
||||
LinkedHashMap<SemanticVersion, ArtifactRelease> copy = new LinkedHashMap<>();
|
||||
releases.forEach((version, release) -> {
|
||||
Objects.requireNonNull(version, "release version");
|
||||
Objects.requireNonNull(release, "release");
|
||||
if (!version.equals(release.version())) {
|
||||
throw new IllegalArgumentException("Release map key does not match release version: " + version);
|
||||
}
|
||||
if (copy.put(version, release) != null) {
|
||||
throw new IllegalArgumentException("Duplicate release version: " + version);
|
||||
}
|
||||
});
|
||||
validateRecommendations(policy, copy);
|
||||
releases = Collections.unmodifiableMap(copy);
|
||||
}
|
||||
|
||||
public Optional<ArtifactRelease> release(SemanticVersion version) {
|
||||
return Optional.ofNullable(releases.get(Objects.requireNonNull(version, "version")));
|
||||
}
|
||||
|
||||
private static void validateRecommendations(
|
||||
JournalPolicy policy,
|
||||
Map<SemanticVersion, ArtifactRelease> releases) {
|
||||
for (PolicyRule recommendation : policy.recommended()) {
|
||||
SemanticVersion version = recommendation.versions().expressions().getFirst()
|
||||
.exactVersion()
|
||||
.orElseThrow(() -> new IllegalArgumentException(
|
||||
"RECOMMENDED must identify exactly one exact major.minor.patch release"));
|
||||
if (!releases.containsKey(version)) {
|
||||
throw new IllegalArgumentException(
|
||||
"RECOMMENDED release " + version
|
||||
+ " does not exist in the same Journal snapshot");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.r35157.nenjim.service.journal.model;
|
||||
|
||||
import com.r35157.nenjim.service.journal.valuetypes.ArtifactCoordinate;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/** Artifact identity and human-facing metadata in one Journal snapshot. */
|
||||
public record JournalMetadata(
|
||||
ArtifactCoordinate coordinate,
|
||||
String description,
|
||||
Optional<URI> informationUri) {
|
||||
public JournalMetadata {
|
||||
Objects.requireNonNull(coordinate, "coordinate");
|
||||
Objects.requireNonNull(description, "description");
|
||||
if (description.isBlank()) {
|
||||
throw new IllegalArgumentException("Journal description cannot be blank");
|
||||
}
|
||||
informationUri = Objects.requireNonNull(informationUri, "informationUri");
|
||||
informationUri.ifPresent(uri -> {
|
||||
if (!uri.isAbsolute()) {
|
||||
throw new IllegalArgumentException("Information URI must be absolute: '" + uri + "'");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.r35157.nenjim.service.journal.model;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Artifact-wide hard rules and selection hints in one Journal snapshot. */
|
||||
public record JournalPolicy(
|
||||
List<PolicyRule> recommended,
|
||||
List<PolicyRule> discouraged,
|
||||
List<PolicyRule> blacklist) {
|
||||
public JournalPolicy {
|
||||
recommended = copyRules(recommended, "recommended");
|
||||
discouraged = copyRules(discouraged, "discouraged");
|
||||
blacklist = copyRules(blacklist, "blacklist");
|
||||
validateRecommendations(recommended);
|
||||
}
|
||||
|
||||
public static JournalPolicy empty() {
|
||||
return new JournalPolicy(List.of(), List.of(), List.of());
|
||||
}
|
||||
|
||||
private static List<PolicyRule> copyRules(List<PolicyRule> rules, String name) {
|
||||
Objects.requireNonNull(rules, name);
|
||||
rules.forEach(rule -> Objects.requireNonNull(rule, name + " rule"));
|
||||
return List.copyOf(rules);
|
||||
}
|
||||
|
||||
private static void validateRecommendations(List<PolicyRule> recommendations) {
|
||||
for (PolicyRule recommendation : recommendations) {
|
||||
if (recommendation.versions().expressions().size() != 1
|
||||
|| !recommendation.versions().expressions().getFirst().isExact()) {
|
||||
throw new IllegalArgumentException(
|
||||
"RECOMMENDED must identify exactly one exact major.minor.patch release");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.r35157.nenjim.service.journal.model;
|
||||
|
||||
import com.r35157.nenjim.service.journal.valuetypes.VersionExpressionSet;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/** One inspectable policy, exclusion, or preference line and its description. */
|
||||
public record PolicyRule(VersionExpressionSet versions, Optional<String> description) {
|
||||
public PolicyRule {
|
||||
Objects.requireNonNull(versions, "versions");
|
||||
if (versions.isEmpty()) {
|
||||
throw new IllegalArgumentException("A policy rule must identify at least one version");
|
||||
}
|
||||
description = Objects.requireNonNull(description, "description");
|
||||
description.ifPresent(value -> {
|
||||
if (value.isBlank()) {
|
||||
throw new IllegalArgumentException("An explicitly supplied rule description cannot be blank");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public PolicyRule(VersionExpressionSet versions) {
|
||||
this(versions, Optional.empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.r35157.nenjim.service.journal.valuetypes;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/** Identifies exactly one Nenjim artifact and its Journal chain. */
|
||||
public record ArtifactCoordinate(String group, String module, String artifact) {
|
||||
private static final Pattern COMPONENT = Pattern.compile("[A-Za-z0-9_.]+");
|
||||
|
||||
public ArtifactCoordinate {
|
||||
group = validate("group", group);
|
||||
module = validate("module", module);
|
||||
artifact = validate("artifact", artifact);
|
||||
}
|
||||
|
||||
public static ArtifactCoordinate parse(String value) {
|
||||
Objects.requireNonNull(value, "value");
|
||||
String[] components = value.split("-", -1);
|
||||
if (components.length != 3) {
|
||||
throw new IllegalArgumentException(
|
||||
"Artifact coordinate must have the form <GROUP>-<MODULE>-<ARTIFACT>: '" + value + "'");
|
||||
}
|
||||
return new ArtifactCoordinate(components[0], components[1], components[2]);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return group + "-" + module + "-" + artifact;
|
||||
}
|
||||
|
||||
private static String validate(String name, String value) {
|
||||
Objects.requireNonNull(value, name);
|
||||
if (!COMPONENT.matcher(value).matches()) {
|
||||
throw new IllegalArgumentException(
|
||||
"Artifact " + name + " must contain only letters, digits, '_' and '.': '" + value + "'");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package com.r35157.nenjim.service.journal.valuetypes;
|
||||
|
||||
import org.apache.commons.codec.binary.Base32;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/** A validated content identity supported by Journal format version 1. */
|
||||
public record ContentDigest(String scheme, String value) {
|
||||
public static final String SHA256 = "sha256";
|
||||
public static final String CID1 = "cid1";
|
||||
|
||||
private static final Pattern SHA256_VALUE = Pattern.compile("[0-9a-f]{64}");
|
||||
private static final Pattern CID1_VALUE = Pattern.compile("b[a-z2-7]+");
|
||||
private static final String BASE32_ALPHABET = "abcdefghijklmnopqrstuvwxyz234567";
|
||||
|
||||
public ContentDigest {
|
||||
Objects.requireNonNull(scheme, "scheme");
|
||||
Objects.requireNonNull(value, "value");
|
||||
switch (scheme) {
|
||||
case SHA256 -> {
|
||||
if (!SHA256_VALUE.matcher(value).matches()) {
|
||||
throw new IllegalArgumentException("Invalid SHA-256 digest: '" + value + "'");
|
||||
}
|
||||
}
|
||||
case CID1 -> validateCidV1(value);
|
||||
default -> throw new IllegalArgumentException("Unsupported content digest scheme: '" + scheme + "'");
|
||||
}
|
||||
}
|
||||
|
||||
public static ContentDigest parse(String text) {
|
||||
Objects.requireNonNull(text, "text");
|
||||
int separator = text.indexOf(':');
|
||||
if (separator <= 0 || separator == text.length() - 1 || text.indexOf(':', separator + 1) >= 0) {
|
||||
throw new IllegalArgumentException("Content digest must have the form <scheme>:<value>: '" + text + "'");
|
||||
}
|
||||
return new ContentDigest(text.substring(0, separator), text.substring(separator + 1));
|
||||
}
|
||||
|
||||
public static ContentDigest sha256(String hexadecimalDigest) {
|
||||
return new ContentDigest(SHA256, hexadecimalDigest);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return scheme + ":" + value;
|
||||
}
|
||||
|
||||
private static void validateCidV1(String value) {
|
||||
if (!CID1_VALUE.matcher(value).matches()) {
|
||||
throw new IllegalArgumentException("Invalid CIDv1 base32 value: '" + value + "'");
|
||||
}
|
||||
|
||||
byte[] decoded;
|
||||
try {
|
||||
decoded = new Base32().decode(value.substring(1));
|
||||
} catch (IllegalArgumentException exception) {
|
||||
throw new IllegalArgumentException("Invalid CIDv1 base32 value: '" + value + "'", exception);
|
||||
}
|
||||
if (decoded.length == 0) {
|
||||
throw new IllegalArgumentException("Invalid empty CIDv1 value");
|
||||
}
|
||||
String encoded = encodeCanonicalBase32(decoded);
|
||||
if (!value.substring(1).equals(encoded)) {
|
||||
throw new IllegalArgumentException("CIDv1 must use canonical unpadded lowercase Base32: '" + value + "'");
|
||||
}
|
||||
|
||||
VarInt version = readVarInt(decoded, 0, "CID version");
|
||||
if (version.value != 1) {
|
||||
throw new IllegalArgumentException("CID must encode version 1: '" + value + "'");
|
||||
}
|
||||
VarInt codec = readVarInt(decoded, version.nextOffset, "CID multicodec");
|
||||
if (codec.value <= 0) {
|
||||
throw new IllegalArgumentException("CIDv1 multicodec must be positive: '" + value + "'");
|
||||
}
|
||||
VarInt hashCode = readVarInt(decoded, codec.nextOffset, "CID multihash code");
|
||||
if (hashCode.value <= 0) {
|
||||
throw new IllegalArgumentException("CIDv1 multihash code must be positive: '" + value + "'");
|
||||
}
|
||||
VarInt hashLength = readVarInt(decoded, hashCode.nextOffset, "CID multihash length");
|
||||
long remaining = decoded.length - hashLength.nextOffset;
|
||||
if (hashLength.value <= 0 || hashLength.value != remaining) {
|
||||
throw new IllegalArgumentException("CIDv1 multihash length does not match its digest: '" + value + "'");
|
||||
}
|
||||
}
|
||||
|
||||
private static String encodeCanonicalBase32(byte[] bytes) {
|
||||
StringBuilder encoded = new StringBuilder((bytes.length * Byte.SIZE + 4) / 5);
|
||||
int buffer = 0;
|
||||
int bufferedBits = 0;
|
||||
for (byte current : bytes) {
|
||||
buffer = (buffer << Byte.SIZE) | Byte.toUnsignedInt(current);
|
||||
bufferedBits += Byte.SIZE;
|
||||
while (bufferedBits >= 5) {
|
||||
bufferedBits -= 5;
|
||||
encoded.append(BASE32_ALPHABET.charAt((buffer >>> bufferedBits) & 0x1f));
|
||||
}
|
||||
buffer = bufferedBits == 0 ? 0 : buffer & ((1 << bufferedBits) - 1);
|
||||
}
|
||||
if (bufferedBits > 0) {
|
||||
encoded.append(BASE32_ALPHABET.charAt((buffer << (5 - bufferedBits)) & 0x1f));
|
||||
}
|
||||
return encoded.toString();
|
||||
}
|
||||
|
||||
private static VarInt readVarInt(byte[] bytes, int offset, String label) {
|
||||
long value = 0;
|
||||
for (int index = offset; index < bytes.length; index++) {
|
||||
int encodedBytes = index - offset;
|
||||
if (encodedBytes >= 9) {
|
||||
throw new IllegalArgumentException(label + " varint overflows the supported long range");
|
||||
}
|
||||
int current = Byte.toUnsignedInt(bytes[index]);
|
||||
int shift = encodedBytes * 7;
|
||||
int payload = current & 0x7f;
|
||||
if (payload > (Long.MAX_VALUE >>> shift)) {
|
||||
throw new IllegalArgumentException(label + " varint overflows the supported long range");
|
||||
}
|
||||
value |= (long) payload << shift;
|
||||
if ((current & 0x80) == 0) {
|
||||
if (encodedBytes + 1 != minimalVarIntLength(value)) {
|
||||
throw new IllegalArgumentException(label + " uses a non-minimal varint encoding");
|
||||
}
|
||||
return new VarInt(value, index + 1);
|
||||
}
|
||||
}
|
||||
throw new IllegalArgumentException(label + " contains an unterminated varint");
|
||||
}
|
||||
|
||||
private static int minimalVarIntLength(long value) {
|
||||
if (value == 0) {
|
||||
return 1;
|
||||
}
|
||||
int significantBits = Long.SIZE - Long.numberOfLeadingZeros(value);
|
||||
return (significantBits + 6) / 7;
|
||||
}
|
||||
|
||||
private record VarInt(long value, int nextOffset) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.r35157.nenjim.service.journal.valuetypes;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/** A scheme-qualified dependency target supported by Journal format version 1. */
|
||||
public record DependencyTarget(String scheme, ArtifactCoordinate coordinate) {
|
||||
public static final String NENJIM_SCHEME = "nenjim";
|
||||
|
||||
public DependencyTarget {
|
||||
Objects.requireNonNull(scheme, "scheme");
|
||||
Objects.requireNonNull(coordinate, "coordinate");
|
||||
if (!NENJIM_SCHEME.equals(scheme)) {
|
||||
throw new IllegalArgumentException("Unsupported dependency scheme: '" + scheme + "'");
|
||||
}
|
||||
}
|
||||
|
||||
public static DependencyTarget nenjim(ArtifactCoordinate coordinate) {
|
||||
return new DependencyTarget(NENJIM_SCHEME, coordinate);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return scheme + ":" + coordinate;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.r35157.nenjim.service.journal.valuetypes;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.format.DateTimeFormatterBuilder;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.time.format.ResolverStyle;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
|
||||
/** UTC publication time of a complete Journal worldview. */
|
||||
public record JournalVersion(Instant instant) implements Comparable<JournalVersion> {
|
||||
private static final DateTimeFormatter FORMATTER = new DateTimeFormatterBuilder()
|
||||
.appendPattern("uuuuMMddHHmmssSSS")
|
||||
.appendLiteral('Z')
|
||||
.toFormatter(Locale.ROOT)
|
||||
.withResolverStyle(ResolverStyle.STRICT);
|
||||
|
||||
public JournalVersion {
|
||||
Objects.requireNonNull(instant, "instant");
|
||||
if (instant.getNano() % 1_000_000 != 0) {
|
||||
throw new IllegalArgumentException("Journal version must have millisecond precision");
|
||||
}
|
||||
}
|
||||
|
||||
public static JournalVersion parse(String value) {
|
||||
Objects.requireNonNull(value, "value");
|
||||
try {
|
||||
LocalDateTime timestamp = LocalDateTime.parse(value, FORMATTER);
|
||||
return new JournalVersion(timestamp.toInstant(ZoneOffset.UTC));
|
||||
} catch (DateTimeParseException exception) {
|
||||
throw new IllegalArgumentException(
|
||||
"Expected UTC timestamp in uuuuMMddHHmmssSSS'Z' format: '" + value + "'", exception);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public int compareTo(JournalVersion other) {
|
||||
return instant.compareTo(Objects.requireNonNull(other, "other").instant);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return FORMATTER.format(LocalDateTime.ofInstant(instant, ZoneOffset.UTC));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package com.r35157.nenjim.service.journal.valuetypes;
|
||||
|
||||
import com.r35157.libs.valuetypes.basic.SemanticVersion;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/** A normalized, lower-inclusive and upper-exclusive set of stable releases. */
|
||||
public final class VersionExpression {
|
||||
private final Boundary lowerInclusive;
|
||||
private final Boundary upperExclusive;
|
||||
|
||||
public VersionExpression(Boundary lowerInclusive, Boundary upperExclusive) {
|
||||
this.lowerInclusive = Objects.requireNonNull(lowerInclusive, "lowerInclusive");
|
||||
this.upperExclusive = Objects.requireNonNull(upperExclusive, "upperExclusive");
|
||||
if (lowerInclusive.compareTo(upperExclusive) >= 0) {
|
||||
throw new IllegalArgumentException("Version expression must not be empty or reversed");
|
||||
}
|
||||
}
|
||||
|
||||
public static VersionExpression exact(SemanticVersion version) {
|
||||
Boundary lower = Boundary.from(Objects.requireNonNull(version, "version"));
|
||||
return new VersionExpression(lower, lower.nextPatch());
|
||||
}
|
||||
|
||||
public static VersionExpression minorSeries(int major, int minor) {
|
||||
if (major < 0 || minor < 0) {
|
||||
throw new IllegalArgumentException("Version elements cannot be negative");
|
||||
}
|
||||
Boundary lower = new Boundary(major, minor, 0);
|
||||
return new VersionExpression(lower, lower.nextMinor());
|
||||
}
|
||||
|
||||
public Boundary lowerInclusive() {
|
||||
return lowerInclusive;
|
||||
}
|
||||
|
||||
public Boundary upperExclusive() {
|
||||
return upperExclusive;
|
||||
}
|
||||
|
||||
public boolean contains(SemanticVersion version) {
|
||||
Boundary candidate = Boundary.from(Objects.requireNonNull(version, "version"));
|
||||
return lowerInclusive.compareTo(candidate) <= 0 && candidate.compareTo(upperExclusive) < 0;
|
||||
}
|
||||
|
||||
public boolean isExact() {
|
||||
return upperExclusive.equals(lowerInclusive.nextPatch())
|
||||
&& lowerInclusive.major <= Integer.MAX_VALUE
|
||||
&& lowerInclusive.minor <= Integer.MAX_VALUE
|
||||
&& lowerInclusive.patch <= Integer.MAX_VALUE;
|
||||
}
|
||||
|
||||
public Optional<SemanticVersion> exactVersion() {
|
||||
if (!isExact()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.of(new SemanticVersion(
|
||||
(int) lowerInclusive.major,
|
||||
(int) lowerInclusive.minor,
|
||||
(int) lowerInclusive.patch));
|
||||
}
|
||||
|
||||
VersionExpression merge(VersionExpression other) {
|
||||
if (upperExclusive.compareTo(other.lowerInclusive) < 0
|
||||
|| other.upperExclusive.compareTo(lowerInclusive) < 0) {
|
||||
throw new IllegalArgumentException("Cannot merge disjoint version expressions");
|
||||
}
|
||||
Boundary lower = lowerInclusive.compareTo(other.lowerInclusive) <= 0
|
||||
? lowerInclusive : other.lowerInclusive;
|
||||
Boundary upper = upperExclusive.compareTo(other.upperExclusive) >= 0
|
||||
? upperExclusive : other.upperExclusive;
|
||||
return new VersionExpression(lower, upper);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object other) {
|
||||
return this == other || other instanceof VersionExpression expression
|
||||
&& lowerInclusive.equals(expression.lowerInclusive)
|
||||
&& upperExclusive.equals(expression.upperExclusive);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(lowerInclusive, upperExclusive);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "[" + lowerInclusive + "-->" + upperExclusive + ")";
|
||||
}
|
||||
|
||||
/** A fully expanded stable-version boundary used by normalized expressions. */
|
||||
public record Boundary(long major, long minor, long patch) implements Comparable<Boundary> {
|
||||
public Boundary {
|
||||
if (major < 0 || minor < 0 || patch < 0) {
|
||||
throw new IllegalArgumentException("Version boundary elements cannot be negative");
|
||||
}
|
||||
}
|
||||
|
||||
public static Boundary from(SemanticVersion version) {
|
||||
Objects.requireNonNull(version, "version");
|
||||
return new Boundary(version.major(), version.minor(), version.patch());
|
||||
}
|
||||
|
||||
public Boundary nextPatch() {
|
||||
return new Boundary(major, minor, Math.addExact(patch, 1));
|
||||
}
|
||||
|
||||
public Boundary nextMinor() {
|
||||
return new Boundary(major, Math.addExact(minor, 1), 0);
|
||||
}
|
||||
|
||||
public Boundary nextMajor() {
|
||||
return new Boundary(Math.addExact(major, 1), 0, 0);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int compareTo(Boundary other) {
|
||||
int majorComparison = Long.compare(major, other.major);
|
||||
if (majorComparison != 0) return majorComparison;
|
||||
int minorComparison = Long.compare(minor, other.minor);
|
||||
return minorComparison != 0 ? minorComparison : Long.compare(patch, other.patch);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return major + "." + minor + "." + patch;
|
||||
}
|
||||
}
|
||||
}
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
package com.r35157.nenjim.service.journal.valuetypes;
|
||||
|
||||
import com.r35157.libs.valuetypes.basic.SemanticVersion;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/** An immutable normalized union of version expressions. */
|
||||
public final class VersionExpressionSet {
|
||||
private final List<VersionExpression> expressions;
|
||||
|
||||
public VersionExpressionSet(Collection<VersionExpression> expressions) {
|
||||
Objects.requireNonNull(expressions, "expressions");
|
||||
ArrayList<VersionExpression> sorted = new ArrayList<>(expressions.size());
|
||||
for (VersionExpression expression : expressions) {
|
||||
sorted.add(Objects.requireNonNull(expression, "expression"));
|
||||
}
|
||||
sorted.sort(Comparator.comparing(VersionExpression::lowerInclusive));
|
||||
|
||||
ArrayList<VersionExpression> normalized = new ArrayList<>();
|
||||
for (VersionExpression expression : sorted) {
|
||||
if (normalized.isEmpty()) {
|
||||
normalized.add(expression);
|
||||
continue;
|
||||
}
|
||||
VersionExpression current = normalized.getLast();
|
||||
if (current.upperExclusive().compareTo(expression.lowerInclusive()) >= 0) {
|
||||
normalized.set(normalized.size() - 1, current.merge(expression));
|
||||
} else {
|
||||
normalized.add(expression);
|
||||
}
|
||||
}
|
||||
this.expressions = List.copyOf(normalized);
|
||||
}
|
||||
|
||||
public static VersionExpressionSet empty() {
|
||||
return new VersionExpressionSet(List.of());
|
||||
}
|
||||
|
||||
public static VersionExpressionSet of(VersionExpression... expressions) {
|
||||
return new VersionExpressionSet(List.of(expressions));
|
||||
}
|
||||
|
||||
public List<VersionExpression> expressions() {
|
||||
return expressions;
|
||||
}
|
||||
|
||||
public boolean isEmpty() {
|
||||
return expressions.isEmpty();
|
||||
}
|
||||
|
||||
public boolean contains(SemanticVersion version) {
|
||||
return expressions.stream().anyMatch(expression -> expression.contains(version));
|
||||
}
|
||||
|
||||
public VersionExpressionSet union(VersionExpressionSet other) {
|
||||
Objects.requireNonNull(other, "other");
|
||||
ArrayList<VersionExpression> combined = new ArrayList<>(expressions);
|
||||
combined.addAll(other.expressions);
|
||||
return new VersionExpressionSet(combined);
|
||||
}
|
||||
|
||||
public VersionExpressionSet subtract(VersionExpressionSet exclusions) {
|
||||
Objects.requireNonNull(exclusions, "exclusions");
|
||||
ArrayList<VersionExpression> remaining = new ArrayList<>();
|
||||
|
||||
for (VersionExpression included : expressions) {
|
||||
VersionExpression.Boundary cursor = included.lowerInclusive();
|
||||
for (VersionExpression excluded : exclusions.expressions) {
|
||||
if (excluded.upperExclusive().compareTo(cursor) <= 0) continue;
|
||||
if (excluded.lowerInclusive().compareTo(included.upperExclusive()) >= 0) break;
|
||||
|
||||
if (cursor.compareTo(excluded.lowerInclusive()) < 0) {
|
||||
VersionExpression.Boundary fragmentUpper = minimum(
|
||||
excluded.lowerInclusive(), included.upperExclusive());
|
||||
if (cursor.compareTo(fragmentUpper) < 0) {
|
||||
remaining.add(new VersionExpression(cursor, fragmentUpper));
|
||||
}
|
||||
}
|
||||
if (excluded.upperExclusive().compareTo(cursor) > 0) {
|
||||
cursor = maximum(cursor, excluded.upperExclusive());
|
||||
}
|
||||
if (cursor.compareTo(included.upperExclusive()) >= 0) break;
|
||||
}
|
||||
if (cursor.compareTo(included.upperExclusive()) < 0) {
|
||||
remaining.add(new VersionExpression(cursor, included.upperExclusive()));
|
||||
}
|
||||
}
|
||||
return new VersionExpressionSet(remaining);
|
||||
}
|
||||
|
||||
public boolean containsAll(VersionExpressionSet candidate) {
|
||||
Objects.requireNonNull(candidate, "candidate");
|
||||
return candidate.subtract(this).isEmpty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object other) {
|
||||
return this == other || other instanceof VersionExpressionSet set
|
||||
&& expressions.equals(set.expressions);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return expressions.hashCode();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return expressions.toString();
|
||||
}
|
||||
|
||||
private static VersionExpression.Boundary minimum(
|
||||
VersionExpression.Boundary left,
|
||||
VersionExpression.Boundary right) {
|
||||
return left.compareTo(right) <= 0 ? left : right;
|
||||
}
|
||||
|
||||
private static VersionExpression.Boundary maximum(
|
||||
VersionExpression.Boundary left,
|
||||
VersionExpression.Boundary right) {
|
||||
return left.compareTo(right) >= 0 ? left : right;
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
package com.r35157.nenjim.valuetypes.journal;
|
||||
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
//import com.r35157.libs.valuetypes.basic.Id;
|
||||
|
||||
public record JournalId(
|
||||
@NotNull String value
|
||||
) { //implements Id {
|
||||
public static JournalId of(String journalId) {
|
||||
return new JournalId(journalId);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user