From 4f70226a38e98aee7e69713e43f12622751329ef Mon Sep 17 00:00:00 2001
From: Fayupable <90789180+Fayupable@users.noreply.github.com>
Date: Fri, 24 Jul 2026 18:32:45 +0300
Subject: [PATCH 1/2] Fix signed right shift in SnowflakeId and add
compile-time validation for @SnowflakeGeneratedId
---
CHANGELOG.md | 16 +++
README.md | 22 ++++
pom.xml | 2 +-
snowflake-benchmark/pom.xml | 4 +-
snowflake-core/pom.xml | 2 +-
.../com/fayupable/snowflake/SnowflakeId.java | 4 +-
.../snowflake/SnowflakeIdGeneratorTest.java | 10 ++
snowflake-jpa-spring/pom.xml | 21 +++-
snowflake-jpa/pom.xml | 2 +-
.../SnowflakeGeneratedIdProcessor.java | 62 ++++++++++
.../javax.annotation.processing.Processor | 1 +
.../SnowflakeGeneratedIdProcessorTest.java | 110 ++++++++++++++++++
12 files changed, 248 insertions(+), 8 deletions(-)
create mode 100644 snowflake-jpa/src/main/java/com/fayupable/snowflake/jpa/processor/SnowflakeGeneratedIdProcessor.java
create mode 100644 snowflake-jpa/src/main/resources/META-INF/services/javax.annotation.processing.Processor
create mode 100644 snowflake-jpa/src/test/java/com/fayupable/snowflake/jpa/processor/SnowflakeGeneratedIdProcessorTest.java
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a9892bb..2da4d54 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Fixed
+- `SnowflakeId.fromLong()` used a signed right shift (`>>`) instead of an unsigned one (`>>>`), inconsistent with
+ the internal decoding logic in `Moment`. Ordinary generated ids were never affected, since their sign bit is
+ always 0, but a caller passing a negative `long` into this public method would get corrupted `nodeId` and
+ `timestamp` values. Fixed to use `>>>` consistently.
+
+### Added
+- Compile-time type validation for `@SnowflakeGeneratedId` in `snowflake-jpa`: an annotation processor now fails
+ the build if the annotation is applied to a field or getter whose type is not `long` or `Long`, instead of only
+ failing at runtime the first time Hibernate tries to persist the entity. Consumers using Maven must add
+ `snowflake-jpa` to their `maven-compiler-plugin`'s `annotationProcessorPaths` for the check to run reliably,
+ since automatic classpath discovery of annotation processors is not guaranteed across all Maven Compiler Plugin
+ versions; see the README for the exact configuration.
+
+## [1.1.0] - 2026-07-24
+
### Added
- `SnowflakeMetrics` output port in `snowflake-core`, with a `NoOpSnowflakeMetrics` default so the generator never
depends on a metrics backend directly.
diff --git a/README.md b/README.md
index 4536d26..ef4c72e 100644
--- a/README.md
+++ b/README.md
@@ -147,6 +147,28 @@ private Long id;
A plain `@GeneratedValue(strategy = GenerationType.AUTO)` on its own does not produce Snowflake IDs. `GenerationType` is a fixed JPA enum that has no Snowflake option, so the generator must always be referenced explicitly, either through `@SnowflakeGeneratedId` or through the `generator` name plus `@GenericGenerator` pairing shown above.
+### Compile-time type checking
+
+`@SnowflakeGeneratedId` can only be applied to a field or getter of type `long` or `Long`. Applying it to any other type (`String`, `Integer`, `UUID`, and so on) fails the build at compile time instead of failing at runtime the first time Hibernate tries to persist the entity.
+
+This check is implemented as an annotation processor bundled in `snowflake-jpa`. Automatic discovery of annotation processors from the compile classpath is not guaranteed across all Maven Compiler Plugin versions, so for the check to run reliably, add `snowflake-jpa` to `annotationProcessorPaths` explicitly:
+
+```xml
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+
+
+
+ com.github.Fayupable.snowflake-id-java
+ snowflake-jpa
+ v1.1.1
+
+
+
+
+```
+
### Hibernate without Spring
Depend on `snowflake-jpa` directly. The `@SnowflakeGeneratedId` annotation works the same way, but since there is no Spring context to wire the generator automatically, register it once at application startup, before the first entity is persisted:
diff --git a/pom.xml b/pom.xml
index 85c2375..0b2cfb8 100644
--- a/pom.xml
+++ b/pom.xml
@@ -6,7 +6,7 @@
com.fayupable
snowflake-id-java
- 1.1.0
+ 1.1.1
pom
diff --git a/snowflake-benchmark/pom.xml b/snowflake-benchmark/pom.xml
index 2d71710..81d39ea 100644
--- a/snowflake-benchmark/pom.xml
+++ b/snowflake-benchmark/pom.xml
@@ -6,7 +6,7 @@
com.fayupable
snowflake-benchmark
- 1.0.0
+ 1.1.1
21
@@ -19,7 +19,7 @@
com.fayupable
snowflake-core
- 1.0.0
+ 1.1.1
org.openjdk.jmh
diff --git a/snowflake-core/pom.xml b/snowflake-core/pom.xml
index 0fbf9cb..a14c713 100644
--- a/snowflake-core/pom.xml
+++ b/snowflake-core/pom.xml
@@ -7,7 +7,7 @@
com.fayupable
snowflake-id-java
- 1.1.0
+ 1.1.1
snowflake-core
diff --git a/snowflake-core/src/main/java/com/fayupable/snowflake/SnowflakeId.java b/snowflake-core/src/main/java/com/fayupable/snowflake/SnowflakeId.java
index c88a25d..5fa44a1 100644
--- a/snowflake-core/src/main/java/com/fayupable/snowflake/SnowflakeId.java
+++ b/snowflake-core/src/main/java/com/fayupable/snowflake/SnowflakeId.java
@@ -10,8 +10,8 @@ public record SnowflakeId(long value, long timestamp, long nodeId, long sequence
public static SnowflakeId fromLong(long id, SnowflakeConfig config) {
long sequence = id & config.maxSequence();
- long nodeId = (id >> config.nodeShift()) & ((1L << config.nodeBits()) - 1);
- long timestamp = (id >> config.timestampShift()) + config.epoch();
+ long nodeId = (id >>> config.nodeShift()) & ((1L << config.nodeBits()) - 1);
+ long timestamp = (id >>> config.timestampShift()) + config.epoch();
return new SnowflakeId(id, timestamp, nodeId, sequence);
}
}
\ No newline at end of file
diff --git a/snowflake-core/src/test/java/com/fayupable/snowflake/SnowflakeIdGeneratorTest.java b/snowflake-core/src/test/java/com/fayupable/snowflake/SnowflakeIdGeneratorTest.java
index d027435..6439371 100644
--- a/snowflake-core/src/test/java/com/fayupable/snowflake/SnowflakeIdGeneratorTest.java
+++ b/snowflake-core/src/test/java/com/fayupable/snowflake/SnowflakeIdGeneratorTest.java
@@ -166,6 +166,16 @@ void decomposesGeneratedId() {
assertEquals(NODE_ID, parsed.nodeId());
assertEquals(0L, parsed.sequence());
}
+
+ @Test
+ @DisplayName("uses unsigned shifts so a crafted negative id does not corrupt decoded fields")
+ void decomposesNegativeIdWithUnsignedShift() {
+ SnowflakeConfig config = SnowflakeConfig.defaultConfig(EPOCH, NODE_ID);
+
+ SnowflakeId parsed = SnowflakeId.fromLong(Long.MIN_VALUE, config);
+
+ assertTrue(parsed.timestamp() > EPOCH);
+ }
}
@Nested
diff --git a/snowflake-jpa-spring/pom.xml b/snowflake-jpa-spring/pom.xml
index 2d3e6d0..f40c32d 100644
--- a/snowflake-jpa-spring/pom.xml
+++ b/snowflake-jpa-spring/pom.xml
@@ -7,7 +7,7 @@
com.fayupable
snowflake-id-java
- 1.1.0
+ 1.1.1
snowflake-jpa-spring
@@ -54,4 +54,23 @@
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+ 3.13.0
+
+
+
+ com.fayupable
+ snowflake-jpa
+ ${project.version}
+
+
+
+
+
+
+
diff --git a/snowflake-jpa/pom.xml b/snowflake-jpa/pom.xml
index 53f0893..8d55eb8 100644
--- a/snowflake-jpa/pom.xml
+++ b/snowflake-jpa/pom.xml
@@ -7,7 +7,7 @@
com.fayupable
snowflake-id-java
- 1.1.0
+ 1.1.1
snowflake-jpa
diff --git a/snowflake-jpa/src/main/java/com/fayupable/snowflake/jpa/processor/SnowflakeGeneratedIdProcessor.java b/snowflake-jpa/src/main/java/com/fayupable/snowflake/jpa/processor/SnowflakeGeneratedIdProcessor.java
new file mode 100644
index 0000000..4822396
--- /dev/null
+++ b/snowflake-jpa/src/main/java/com/fayupable/snowflake/jpa/processor/SnowflakeGeneratedIdProcessor.java
@@ -0,0 +1,62 @@
+package com.fayupable.snowflake.jpa.processor;
+
+import com.fayupable.snowflake.jpa.SnowflakeGeneratedId;
+
+import javax.annotation.processing.AbstractProcessor;
+import javax.annotation.processing.RoundEnvironment;
+import javax.annotation.processing.SupportedAnnotationTypes;
+import javax.annotation.processing.SupportedSourceVersion;
+import javax.lang.model.SourceVersion;
+import javax.lang.model.element.Element;
+import javax.lang.model.element.ElementKind;
+import javax.lang.model.element.ExecutableElement;
+import javax.lang.model.element.TypeElement;
+import javax.lang.model.type.DeclaredType;
+import javax.lang.model.type.TypeKind;
+import javax.lang.model.type.TypeMirror;
+import javax.tools.Diagnostic;
+import java.util.Set;
+
+/**
+ * Fails compilation when {@link SnowflakeGeneratedId} is applied to a field or
+ * getter whose type is not {@code long} or {@code Long}. Without this check,
+ * a mismatched type only fails at runtime, the first time Hibernate tries to
+ * assign the generated value to the entity.
+ */
+@SupportedAnnotationTypes("com.fayupable.snowflake.jpa.SnowflakeGeneratedId")
+@SupportedSourceVersion(SourceVersion.RELEASE_21)
+public class SnowflakeGeneratedIdProcessor extends AbstractProcessor {
+
+ @Override
+ public boolean process(Set extends TypeElement> annotations, RoundEnvironment roundEnv) {
+ for (Element element : roundEnv.getElementsAnnotatedWith(SnowflakeGeneratedId.class)) {
+ TypeMirror annotatedType = resolveAnnotatedType(element);
+ if (!isLongType(annotatedType)) {
+ processingEnv.getMessager().printMessage(
+ Diagnostic.Kind.ERROR,
+ "@SnowflakeGeneratedId can only be applied to a field or getter of type "
+ + "long or Long, but found type " + annotatedType,
+ element);
+ }
+ }
+ return true;
+ }
+
+ private TypeMirror resolveAnnotatedType(Element element) {
+ if (element.getKind() == ElementKind.METHOD) {
+ return ((ExecutableElement) element).getReturnType();
+ }
+ return element.asType();
+ }
+
+ private boolean isLongType(TypeMirror type) {
+ if (type.getKind() == TypeKind.LONG) {
+ return true;
+ }
+ if (type.getKind() == TypeKind.DECLARED) {
+ String qualifiedName = ((DeclaredType) type).asElement().toString();
+ return "java.lang.Long".equals(qualifiedName);
+ }
+ return false;
+ }
+}
diff --git a/snowflake-jpa/src/main/resources/META-INF/services/javax.annotation.processing.Processor b/snowflake-jpa/src/main/resources/META-INF/services/javax.annotation.processing.Processor
new file mode 100644
index 0000000..7edc8ba
--- /dev/null
+++ b/snowflake-jpa/src/main/resources/META-INF/services/javax.annotation.processing.Processor
@@ -0,0 +1 @@
+com.fayupable.snowflake.jpa.processor.SnowflakeGeneratedIdProcessor
diff --git a/snowflake-jpa/src/test/java/com/fayupable/snowflake/jpa/processor/SnowflakeGeneratedIdProcessorTest.java b/snowflake-jpa/src/test/java/com/fayupable/snowflake/jpa/processor/SnowflakeGeneratedIdProcessorTest.java
new file mode 100644
index 0000000..fc919f0
--- /dev/null
+++ b/snowflake-jpa/src/test/java/com/fayupable/snowflake/jpa/processor/SnowflakeGeneratedIdProcessorTest.java
@@ -0,0 +1,110 @@
+package com.fayupable.snowflake.jpa.processor;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import javax.tools.Diagnostic;
+import javax.tools.DiagnosticCollector;
+import javax.tools.JavaCompiler;
+import javax.tools.JavaFileObject;
+import javax.tools.SimpleJavaFileObject;
+import javax.tools.StandardJavaFileManager;
+import javax.tools.StandardLocation;
+import javax.tools.ToolProvider;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.net.URI;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Arrays;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Compiles small in-memory sources with the real javac compiler and this
+ * processor attached, to prove the compile-time type check actually fires
+ * rather than trusting the processor logic by inspection alone.
+ */
+class SnowflakeGeneratedIdProcessorTest {
+
+ @Test
+ @DisplayName("fails compilation when applied to a non-Long field")
+ void rejectsNonLongField() {
+ String source = """
+ package com.example.generated;
+
+ import com.fayupable.snowflake.jpa.SnowflakeGeneratedId;
+
+ class BadEntity {
+ @SnowflakeGeneratedId
+ private String id;
+ }
+ """;
+
+ DiagnosticCollector diagnostics = compile("BadEntity", source);
+
+ boolean hasExpectedError = diagnostics.getDiagnostics().stream()
+ .anyMatch(d -> d.getKind() == Diagnostic.Kind.ERROR
+ && d.getMessage(null).contains("@SnowflakeGeneratedId"));
+
+ assertTrue(hasExpectedError, "expected a compile error mentioning @SnowflakeGeneratedId");
+ }
+
+ @Test
+ @DisplayName("compiles cleanly when applied to a Long field")
+ void acceptsLongField() {
+ String source = """
+ package com.example.generated;
+
+ import com.fayupable.snowflake.jpa.SnowflakeGeneratedId;
+
+ class GoodEntity {
+ @SnowflakeGeneratedId
+ private Long id;
+ }
+ """;
+
+ DiagnosticCollector diagnostics = compile("GoodEntity", source);
+
+ boolean hasError = diagnostics.getDiagnostics().stream()
+ .anyMatch(d -> d.getKind() == Diagnostic.Kind.ERROR);
+
+ assertFalse(hasError, "did not expect any compile errors for a Long field");
+ }
+
+ private DiagnosticCollector compile(String className, String source) {
+ JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
+ DiagnosticCollector diagnostics = new DiagnosticCollector<>();
+ StandardJavaFileManager fileManager =
+ compiler.getStandardFileManager(diagnostics, null, null);
+
+ try {
+ Path classOutputDir = Files.createTempDirectory("snowflake-processor-test");
+ classOutputDir.toFile().deleteOnExit();
+ fileManager.setLocation(StandardLocation.CLASS_OUTPUT, List.of(classOutputDir.toFile()));
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+
+ JavaFileObject sourceObject = new SimpleJavaFileObject(
+ URI.create("string:///com/example/generated/" + className + ".java"),
+ JavaFileObject.Kind.SOURCE) {
+ @Override
+ public CharSequence getCharContent(boolean ignoreEncodingErrors) {
+ return source;
+ }
+ };
+
+ List options = Arrays.asList(
+ "-classpath", System.getProperty("java.class.path"));
+
+ JavaCompiler.CompilationTask task = compiler.getTask(
+ null, fileManager, diagnostics, options, null, List.of(sourceObject));
+ task.setProcessors(List.of(new SnowflakeGeneratedIdProcessor()));
+ task.call();
+
+ return diagnostics;
+ }
+}
From e2e6a4c7049fd483318bff33556d09236c49f5b7 Mon Sep 17 00:00:00 2001
From: Fayupable <90789180+Fayupable@users.noreply.github.com>
Date: Fri, 24 Jul 2026 18:57:35 +0300
Subject: [PATCH 2/2] Add Maven Compiler Plugin configuration to pom.xml
---
snowflake-jpa/pom.xml | 8 ++++++++
1 file changed, 8 insertions(+)
diff --git a/snowflake-jpa/pom.xml b/snowflake-jpa/pom.xml
index 8d55eb8..cdc8a39 100644
--- a/snowflake-jpa/pom.xml
+++ b/snowflake-jpa/pom.xml
@@ -43,6 +43,14 @@
maven-surefire-plugin
3.2.5
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+ 3.13.0
+
+ none
+
+