Skip to content
View lmcrean's full-sized avatar

Block or report lmcrean

Block user

Prevent this user from interacting with your repositories and sending you notifications. Learn more about blocking users.

You must be logged in to block users.

Maximum 250 characters. Please don't include any personal information such as legal names or email addresses. Markdown supported. This note will be visible to only you.
Report abuse

Contact GitHub support about this user’s behavior. Learn more about reporting abuse.

Report abuse
lmcrean/ReadMe.md

Software developer and Teacher of Computer Science.

Open Source Contributions

Now running in production across millions of business applications.

Google Google

Guava, Java
Core Java libraries used by Google and Android.

  1. Fix resource leak in FileBackedOutputStream to prevent file handle exhaustion
    Fixed file handle exhaustion by adding proper exception handling to ensure FileOutputStream is closed when IOException occurs during memory-to-file transition.

    +96/-1
    diff --git a/guava-tests/test/com/google/common/io/FileBackedOutputStreamTest.java b/guava-tests/test/com/google/common/io/FileBackedOutputStreamTest.java
    index 3cbf5a1028a7..ee7b43059187 100644
    --- a/guava-tests/test/com/google/common/io/FileBackedOutputStreamTest.java
    +++ b/guava-tests/test/com/google/common/io/FileBackedOutputStreamTest.java
    @@ -171,4 +171,90 @@ private static boolean isAndroid() {
       private static boolean isWindows() {
         return OS_NAME.value().startsWith("Windows");
       }
    +
    +  /**
    +   * Test that verifies the resource leak fix for Issue #5756.
    +   *
    +   * This test covers a scenario where we write a smaller amount of data first,
    +   * then write a large amount that crosses the threshold (transitioning from
    +   * "not at threshold" to "over the threshold"). This differs from the existing
    +   * testThreshold() which writes exactly enough bytes to fill the buffer, then
    +   * immediately writes more bytes.
    +   *
    +   * Note: Direct testing of the IOException scenario during write/flush is challenging
    +   * without mocking. This test verifies that normal operation with threshold crossing
    +   * still works correctly with the fix in place.
    +   */
    +  public void testThresholdCrossing_ResourceManagement() throws Exception {
    +    // Test data that will cross the threshold
    +    int threshold = 50;
    +    byte[] beforeThreshold = newPreFilledByteArray(40);
    +    byte[] afterThreshold = newPreFilledByteArray(30);
    +
    +    FileBackedOutputStream out = new FileBackedOutputStream(threshold);
    +    ByteSource source = out.asByteSource();
    +
    +    // Write data that doesn't cross threshold
    +    out.write(beforeThreshold);
    +    assertNull(out.getFile());
    +
    +    // Write data that crosses threshold - this exercises the fixed code path
    +    if (!JAVA_IO_TMPDIR.value().equals("/sdcard")) {
    +      out.write(afterThreshold);
    +      File file = out.getFile();
    +      assertNotNull(file);
    +      assertTrue(file.exists());
    +
    +      // Verify all data was written correctly
    +      byte[] expected = new byte[70];
    +      System.arraycopy(beforeThreshold, 0, expected, 0, 40);
    +      System.arraycopy(afterThreshold, 0, expected, 40, 30);
    +      assertTrue(Arrays.equals(expected, source.read()));
    +
    +      // Clean up
    +      out.close();
    +      out.reset();
    +      assertFalse(file.exists());
    +    }
    +  }
    +
    +  /**
    +   * Test that verifies writes after crossing the threshold work correctly.
    +   *
    +   * Once the threshold is crossed, subsequent writes go to the file. This test
    +   * ensures that continued writing after the initial threshold crossing works
    +   * properly with the resource management fix in place.
    +   */
    +  public void testWriteAfterThresholdCrossing() throws Exception {
    +    // Use a small threshold to force multiple file operations
    +    int threshold = 10;
    +    FileBackedOutputStream out = new FileBackedOutputStream(threshold);
    +    ByteSource source = out.asByteSource();
    +
    +    // Write data in chunks: first below threshold, then crossing it, then after crossing
    +    byte[] chunk1 = newPreFilledByteArray(8);  // Below threshold
    +    byte[] chunk2 = newPreFilledByteArray(5);  // Crosses threshold
    +    byte[] chunk3 = newPreFilledByteArray(20); // More data to file
    +
    +    out.write(chunk1);
    +    assertNull(out.getFile());
    +
    +    if (!JAVA_IO_TMPDIR.value().equals("/sdcard")) {
    +      out.write(chunk2);
    +      File file = out.getFile();
    +      assertNotNull(file);
    +
    +      out.write(chunk3);
    +
    +      // Verify all data is correct
    +      byte[] expected = new byte[33];
    +      System.arraycopy(chunk1, 0, expected, 0, 8);
    +      System.arraycopy(chunk2, 0, expected, 8, 5);
    +      System.arraycopy(chunk3, 0, expected, 13, 20);
    +      assertTrue(Arrays.equals(expected, source.read()));
    +
    +      out.close();
    +      out.reset();
    +    }
    +  }
     }
    diff --git a/guava/src/com/google/common/io/FileBackedOutputStream.java b/guava/src/com/google/common/io/FileBackedOutputStream.java
    index ee7cc83c5d1d..4fe78aac11cd 100644
    --- a/guava/src/com/google/common/io/FileBackedOutputStream.java
    +++ b/guava/src/com/google/common/io/FileBackedOutputStream.java
    @@ -238,13 +238,22 @@ private void update(int len) throws IOException {
             // this is insurance.
             temp.deleteOnExit();
           }
    +      // Create and populate the file, ensuring proper resource management
    +      FileOutputStream transfer = null;
           try {
    -        FileOutputStream transfer = new FileOutputStream(temp);
    +        transfer = new FileOutputStream(temp);
             transfer.write(memory.getBuffer(), 0, memory.getCount());
             transfer.flush();
             // We've successfully transferred the data; switch to writing to file
             out = transfer;
           } catch (IOException e) {
    +        if (transfer != null) {
    +          try {
    +            transfer.close();
    +          } catch (IOException closeException) {
    +            e.addSuppressed(closeException);
    +          }
    +        }
             temp.delete();
             throw e;
           }

    view full diff

  2. Improve error messages for annotation methods on synthetic TypeVariables
    Replaced unhelpful UnsupportedOperationException("methodName") with descriptive error messages explaining why annotations aren't supported on synthetic TypeVariables created by TypeResolver.

    +19/-5
    diff --git a/guava/src/com/google/common/reflect/TypeResolver.java b/guava/src/com/google/common/reflect/TypeResolver.java
    index b28ffbb7228c..a69ddb80c23c 100644
    --- a/guava/src/com/google/common/reflect/TypeResolver.java
    +++ b/guava/src/com/google/common/reflect/TypeResolver.java
    @@ -368,10 +368,12 @@ Type resolveInternal(TypeVariable<?> var, TypeTable forDependants) {
              * by us. And that equality is guaranteed to hold because it doesn't involve the JDK
              * TypeVariable implementation at all.
              *
    -         * TODO: b/147144588 - But what about when the TypeVariable has annotations? Our
    -         * implementation currently doesn't support annotations _at all_. It could at least be made
    -         * to respond to queries about annotations by returning null/empty, but are there situations
    -         * in which it should return something else?
    +         * NOTE: b/147144588 - Custom TypeVariables created by Guava do not preserve annotations.
    +         * This is intentional. The semantics of annotation handling during type resolution are
    +         * unclear and have changed across Java versions. Until there's a clear specification for
    +         * what annotations should mean on resolved TypeVariables with modified bounds, annotation
    +         * methods will throw UnsupportedOperationException. Frameworks requiring annotation
    +         * preservation should use the original TypeVariable when bounds haven't changed.
              */
             if (Types.NativeTypeVariableEquals.NATIVE_TYPE_VARIABLE_ONLY
                 && Arrays.equals(bounds, resolvedBounds)) {
    diff --git a/guava/src/com/google/common/reflect/Types.java b/guava/src/com/google/common/reflect/Types.java
    index 209369d017a4..57c3a3d51862 100644
    --- a/guava/src/com/google/common/reflect/Types.java
    +++ b/guava/src/com/google/common/reflect/Types.java
    @@ -382,7 +382,19 @@ private static final class TypeVariableInvocationHandler implements InvocationHa
           String methodName = method.getName();
           Method typeVariableMethod = typeVariableMethods.get(methodName);
           if (typeVariableMethod == null) {
    -        throw new UnsupportedOperationException(methodName);
    +        // Provide helpful error message for annotation-related methods
    +        if (methodName.equals("getAnnotatedBounds")
    +            || methodName.startsWith("getAnnotation")
    +            || methodName.startsWith("getDeclaredAnnotation")
    +            || methodName.equals("isAnnotationPresent")
    +            || methodName.equals("getAnnotations")
    +            || methodName.equals("getDeclaredAnnotations")) {
    +          throw new UnsupportedOperationException(
    +              "Annotation methods are not supported on synthetic TypeVariables created during type "
    +              + "resolution. The semantics of annotations on resolved types with modified bounds are "
    +              + "undefined. Use the original TypeVariable for annotation access. See b/147144588.");
    +        }
    +        throw new UnsupportedOperationException(methodName);  // Keep original behavior for other methods
           } else {
             try {
               return typeVariableMethod.invoke(typeVariableImpl, args);

    view full diff

  3. Fix Iterators.mergeSorted() to preserve stability for equal elements
    Fixed unstable ordering of equal elements by tracking iterator insertion order and using it as a tiebreaker, ensuring elements from earlier iterators appear before equal elements from later ones.

    +167/-10
    diff --git a/guava-testlib/src/com/google/common/collect/testing/testers/MapPutIfAbsentTester.java b/guava-testlib/src/com/google/common/collect/testing/testers/MapPutIfAbsentTester.java
    index c8bfdc84cfde..c1d2225718a7 100644
    --- a/guava-testlib/src/com/google/common/collect/testing/testers/MapPutIfAbsentTester.java
    +++ b/guava-testlib/src/com/google/common/collect/testing/testers/MapPutIfAbsentTester.java
    @@ -125,4 +125,18 @@ public void testPut_nullValueSupported() {
             getMap().putIfAbsent(nullValueEntry.getKey(), nullValueEntry.getValue()));
         expectAdded(nullValueEntry);
       }
    +
    +  @MapFeature.Require({SUPPORTS_PUT, ALLOWS_NULL_VALUES})
    +  @CollectionSize.Require(absent = ZERO)
    +  public void testPutIfAbsent_replacesNullValue() {
    +    // First, put a null value for an existing key
    +    getMap().put(k0(), null);
    +    assertEquals("Map should contain null value", null, getMap().get(k0()));
    +
    +    // putIfAbsent should replace the null value with the new value
    +    assertNull(
    +        "putIfAbsent(existingKeyWithNullValue, value) should return null",
    +        getMap().putIfAbsent(k0(), v3()));
    +    assertEquals("Map should now contain the new value", v3(), getMap().get(k0()));
    +  }
     }
    diff --git a/guava-tests/test/com/google/common/collect/IteratorsTest.java b/guava-tests/test/com/google/common/collect/IteratorsTest.java
    index b1d09dbbe296..aa8b7cfff2ba 100644
    --- a/guava-tests/test/com/google/common/collect/IteratorsTest.java
    +++ b/guava-tests/test/com/google/common/collect/IteratorsTest.java
    @@ -61,6 +61,7 @@
     import java.util.Arrays;
     import java.util.Collection;
     import java.util.Collections;
    +import java.util.Comparator;
     import java.util.ConcurrentModificationException;
     import java.util.Enumeration;
     import java.util.Iterator;
    @@ -68,6 +69,7 @@
     import java.util.List;
     import java.util.ListIterator;
     import java.util.NoSuchElementException;
    +import java.util.Objects;
     import java.util.RandomAccess;
     import java.util.Set;
     import java.util.Vector;
    @@ -1546,4 +1548,122 @@ public void testPeekingIteratorShortCircuit() {
         assertSame(peek, Iterators.peekingIterator(peek));
         assertSame(peek, Iterators.peekingIterator((Iterator<String>) peek));
       }
    +
    +  // Tests for demonstrating mergeSorted instability (Issue #5773)
    +  // These tests are expected to FAIL with the current implementation,
    +  // demonstrating that mergeSorted() is not stable for equal elements.
    +
    +  public void testMergeSorted_demonstratesInstability_issue5773Example() {
    +    List<TestDatum> left = ImmutableList.of(
    +        new TestDatum("B", 1),
    +        new TestDatum("C", 1)
    +    );
    +
    +    List<TestDatum> right = ImmutableList.of(
    +        new TestDatum("A", 2),
    +        new TestDatum("C", 2)
    +    );
    +
    +    Comparator<TestDatum> comparator = Comparator.comparing(d -> d.letter);
    +
    +    Iterator<TestDatum> merged = Iterators.mergeSorted(
    +        ImmutableList.of(left.iterator(), right.iterator()),
    +        comparator);
    +
    +    List<TestDatum> result = ImmutableList.copyOf(merged);
    +
    +    assertEquals("Should have 4 elements", 4, result.size());
    +    assertEquals("First should be A2", "A", result.get(0).letter);
    +    assertEquals("First should be from right iterator", 2, result.get(0).number);
    +    assertEquals("Second should be B1", "B", result.get(1).letter);
    +    assertEquals("Second should be from left iterator", 1, result.get(1).number);
    +    assertEquals("Third should be C from left iterator (C1) for stability", 1, result.get(2).number);
    +    assertEquals("Fourth should be C from right iterator (C2) for stability", 2, result.get(3).number);
    +  }
    +
    +  public void testMergeSorted_demonstratesInstability_allEqual() {
    +    List<TestDatum> first = ImmutableList.of(
    +        new TestDatum("A", 1),
    +        new TestDatum("A", 2)
    +    );
    +
    +    List<TestDatum> second = ImmutableList.of(
    +        new TestDatum("A", 3),
    +        new TestDatum("A", 4)
    +    );
    +
    +    Comparator<TestDatum> comparator = Comparator.comparing(d -> d.letter);
    +    Iterator<TestDatum> merged = Iterators.mergeSorted(
    +        ImmutableList.of(first.iterator(), second.iterator()),
    +        comparator);
    +
    +    List<TestDatum> result = ImmutableList.copyOf(merged);
    +
    +    assertEquals("Should have 4 elements", 4, result.size());
    +    assertEquals("First should be A1 for stability", 1, result.get(0).number);
    +    assertEquals("Second should be A2 for stability", 2, result.get(1).number);
    +    assertEquals("Third should be A3 for stability", 3, result.get(2).number);
    +    assertEquals("Fourth should be A4 for stability", 4, result.get(3).number);
    +  }
    +
    +  private static class TestDatum {
    +    final String letter;
    +    final int number;
    +
    +    TestDatum(String letter, int number) {
    +      this.letter = letter;
    +      this.number = number;
    +    }
    +
    +    @Override
    +    public String toString() {
    +      return letter + number;
    +    }
    +
    +    @Override
    +    public boolean equals(Object o) {
    +      if (!(o instanceof TestDatum)) return false;
    +      TestDatum other = (TestDatum) o;
    +      return letter.equals(other.letter) && number == other.number;
    +    }
    +
    +    @Override
    +    public int hashCode() {
    +      return Objects.hash(letter, number);
    +    }
    +  }
     }
    diff --git a/guava/src/com/google/common/collect/Iterators.java b/guava/src/com/google/common/collect/Iterators.java
    index 0fa2cf03f674..94044bcb6a60 100644
    --- a/guava/src/com/google/common/collect/Iterators.java
    +++ b/guava/src/com/google/common/collect/Iterators.java
    @@ -1294,8 +1294,9 @@ public E peek() {
        * <p>Callers must ensure that the source {@code iterators} are in non-descending order as this
        * method does not sort its input.
        *
    -   * <p>For any equivalent elements across all {@code iterators}, it is undefined which element is
    -   * returned first.
    +   * <p>For any equivalent elements across all {@code iterators}, elements are returned in the order
    +   * of their source iterators. That is, if element A from iterator 1 and element B from iterator 2
    +   * compare as equal, A will be returned before B if iterator 1 was passed before iterator 2.
        *
        * @since 11.0
        */
    @@ -1318,22 +1319,43 @@ public E peek() {
        */
       private static final class MergingIterator<T extends @Nullable Object>
           extends UnmodifiableIterator<T> {
    -    final Queue<PeekingIterator<T>> queue;
    +
    +    private static class IndexedIterator<E extends @Nullable Object> {
    +      final PeekingIterator<E> iterator;
    +      final int index;
    +
    +      IndexedIterator(PeekingIterator<E> iterator, int index) {
    +        this.iterator = iterator;
    +        this.index = index;
    +      }
    +    }
    +
    +    final Queue<IndexedIterator<T>> queue;
    
         MergingIterator(
             Iterable<? extends Iterator<? extends T>> iterators, Comparator<? super T> itemComparator) {
    -      Comparator<PeekingIterator<T>> heapComparator =
    -          (PeekingIterator<T> o1, PeekingIterator<T> o2) ->
    -              itemComparator.compare(o1.peek(), o2.peek());
    +      Comparator<IndexedIterator<T>> heapComparator =
    +          (IndexedIterator<T> o1, IndexedIterator<T> o2) -> {
    +            int result = itemComparator.compare(o1.iterator.peek(), o2.iterator.peek());
    +            if (result == 0) {
    +              return Integer.compare(o1.index, o2.index);
    +            }
    +            return result;
    +          };
    
           queue = new PriorityQueue<>(2, heapComparator);
    
    +      int index = 0;
           for (Iterator<? extends T> iterator : iterators) {
             if (iterator.hasNext()) {
    -          queue.add(Iterators.peekingIterator(iterator));
    +          queue.add(
    +              new IndexedIterator<>(Iterators.peekingIterator(iterator), index));
             }
    +        index++;
           }
         }
    
    @@ -1345,10 +1367,11 @@ public boolean hasNext() {
         @Override
         @ParametricNullness
         public T next() {
    -      PeekingIterator<T> nextIter = queue.remove();
    +      IndexedIterator<T> nextIndexed = queue.remove();
    +      PeekingIterator<T> nextIter = nextIndexed.iterator;
           T next = nextIter.next();
           if (nextIter.hasNext()) {
    -        queue.add(nextIter);
    +        queue.add(nextIndexed);
           }
           return next;
         }

    view full diff

  4. Add tests demonstrating Iterators.mergeSorted() instability
    Added test cases demonstrating the instability problem in Iterators.mergeSorted() as requested by maintainers, verifying the bug exists before the fix PR.

    +134/-0
    diff --git a/guava-testlib/src/com/google/common/collect/testing/testers/MapPutIfAbsentTester.java b/guava-testlib/src/com/google/common/collect/testing/testers/MapPutIfAbsentTester.java
    index c8bfdc84cfde..c1d2225718a7 100644
    --- a/guava-testlib/src/com/google/common/collect/testing/testers/MapPutIfAbsentTester.java
    +++ b/guava-testlib/src/com/google/common/collect/testing/testers/MapPutIfAbsentTester.java
    @@ -125,4 +125,18 @@ public void testPut_nullValueSupported() {
             getMap().putIfAbsent(nullValueEntry.getKey(), nullValueEntry.getValue()));
         expectAdded(nullValueEntry);
       }
    +
    +  @MapFeature.Require({SUPPORTS_PUT, ALLOWS_NULL_VALUES})
    +  @CollectionSize.Require(absent = ZERO)
    +  public void testPutIfAbsent_replacesNullValue() {
    +    // First, put a null value for an existing key
    +    getMap().put(k0(), null);
    +    assertEquals("Map should contain null value", null, getMap().get(k0()));
    +
    +    // putIfAbsent should replace the null value with the new value
    +    assertNull(
    +        "putIfAbsent(existingKeyWithNullValue, value) should return null",
    +        getMap().putIfAbsent(k0(), v3()));
    +    assertEquals("Map should now contain the new value", v3(), getMap().get(k0()));
    +  }
     }
    diff --git a/guava-tests/test/com/google/common/collect/IteratorsTest.java b/guava-tests/test/com/google/common/collect/IteratorsTest.java
    index b1d09dbbe296..aa8b7cfff2ba 100644
    --- a/guava-tests/test/com/google/common/collect/IteratorsTest.java
    +++ b/guava-tests/test/com/google/common/collect/IteratorsTest.java
    @@ -61,6 +61,7 @@
     import java.util.Arrays;
     import java.util.Collection;
     import java.util.Collections;
    +import java.util.Comparator;
     import java.util.ConcurrentModificationException;
     import java.util.Enumeration;
     import java.util.Iterator;
    @@ -68,6 +69,7 @@
     import java.util.List;
     import java.util.ListIterator;
     import java.util.NoSuchElementException;
    +import java.util.Objects;
     import java.util.RandomAccess;
     import java.util.Set;
     import java.util.Vector;
    @@ -1546,4 +1548,122 @@ public void testPeekingIteratorShortCircuit() {
         assertSame(peek, Iterators.peekingIterator(peek));
         assertSame(peek, Iterators.peekingIterator((Iterator<String>) peek));
       }
    +
    +  // Tests for demonstrating mergeSorted instability (Issue #5773)
    +
    +  public void testMergeSorted_demonstratesInstability_issue5773Example() {
    +    List<TestDatum> left = ImmutableList.of(
    +        new TestDatum("B", 1),
    +        new TestDatum("C", 1)
    +    );
    +
    +    List<TestDatum> right = ImmutableList.of(
    +        new TestDatum("A", 2),
    +        new TestDatum("C", 2)
    +    );
    +
    +    Comparator<TestDatum> comparator = Comparator.comparing(d -> d.letter);
    +
    +    Iterator<TestDatum> merged = Iterators.mergeSorted(
    +        ImmutableList.of(left.iterator(), right.iterator()),
    +        comparator);
    +
    +    List<TestDatum> result = ImmutableList.copyOf(merged);
    +
    +    // EXPECTED (if stable): [A2, B1, C1, C2]
    +    // ACTUAL (unstable): Sometimes [A2, B1, C1, C2], sometimes [A2, B1, C2, C1]
    +
    +    assertEquals("Should have 4 elements", 4, result.size());
    +    assertEquals("First should be A2", "A", result.get(0).letter);
    +    assertEquals("First should be from right iterator", 2, result.get(0).number);
    +    assertEquals("Second should be B1", "B", result.get(1).letter);
    +    assertEquals("Second should be from left iterator", 1, result.get(1).number);
    +
    +    // THIS IS THE KEY ASSERTION THAT WILL FAIL:
    +    assertEquals("Third should be C from left iterator (C1) for stability", 1, result.get(2).number);
    +    assertEquals("Fourth should be C from right iterator (C2) for stability", 2, result.get(3).number);
    +  }
    +
    +  public void testMergeSorted_demonstratesInstability_allEqual() {
    +    List<TestDatum> first = ImmutableList.of(
    +        new TestDatum("A", 1),
    +        new TestDatum("A", 2)
    +    );
    +
    +    List<TestDatum> second = ImmutableList.of(
    +        new TestDatum("A", 3),
    +        new TestDatum("A", 4)
    +    );
    +
    +    Comparator<TestDatum> comparator = Comparator.comparing(d -> d.letter);
    +    Iterator<TestDatum> merged = Iterators.mergeSorted(
    +        ImmutableList.of(first.iterator(), second.iterator()),
    +        comparator);
    +
    +    List<TestDatum> result = ImmutableList.copyOf(merged);
    +
    +    // EXPECTED (if stable): [A1, A2, A3, A4] - maintaining iterator order
    +    // ACTUAL (unstable): Order of elements is non-deterministic
    +
    +    assertEquals("Should have 4 elements", 4, result.size());
    +
    +    // These assertions will FAIL non-deterministically:
    +    assertEquals("First should be A1 for stability", 1, result.get(0).number);
    +    assertEquals("Second should be A2 for stability", 2, result.get(1).number);
    +    assertEquals("Third should be A3 for stability", 3, result.get(2).number);
    +    assertEquals("Fourth should be A4 for stability", 4, result.get(3).number);
    +  }
    +
    +  private static class TestDatum {
    +    final String letter;
    +    final int number;
    +
    +    TestDatum(String letter, int number) {
    +      this.letter = letter;
    +      this.number = number;
    +    }
    +
    +    @Override
    +    public String toString() {
    +      return letter + number;
    +    }
    +
    +    @Override
    +    public boolean equals(Object o) {
    +      if (!(o instanceof TestDatum)) return false;
    +      TestDatum other = (TestDatum) o;
    +      return letter.equals(other.letter) && number == other.number;
    +    }
    +
    +    @Override
    +    public int hashCode() {
    +      return Objects.hash(letter, number);
    +    }
    +  }
    +
    +  // Note: These tests are intentionally designed to FAIL with the current
    +  // implementation to demonstrate issue #5773. They will pass once the
    +  // stability fix is applied.
     }

    view full diff

  5. Add test for putIfAbsent to catch implementations that incorrectly ignore null values
    Added test to verify putIfAbsent correctly replaces existing null values, catching non-compliant Map implementations that pass the test suite despite violating the JavaDoc specification.

    +14/-0
    diff --git a/guava-testlib/src/com/google/common/collect/testing/testers/MapPutIfAbsentTester.java b/guava-testlib/src/com/google/common/collect/testing/testers/MapPutIfAbsentTester.java
    index c8bfdc84cfde..c1d2225718a7 100644
    --- a/guava-testlib/src/com/google/common/collect/testing/testers/MapPutIfAbsentTester.java
    +++ b/guava-testlib/src/com/google/common/collect/testing/testers/MapPutIfAbsentTester.java
    @@ -125,4 +125,18 @@ public void testPut_nullValueSupported() {
             getMap().putIfAbsent(nullValueEntry.getKey(), nullValueEntry.getValue()));
         expectAdded(nullValueEntry);
       }
    +
    +  @MapFeature.Require({SUPPORTS_PUT, ALLOWS_NULL_VALUES})
    +  @CollectionSize.Require(absent = ZERO)
    +  public void testPutIfAbsent_replacesNullValue() {
    +    // First, put a null value for an existing key
    +    getMap().put(k0(), null);
    +    assertEquals("Map should contain null value", null, getMap().get(k0()));
    +
    +    // putIfAbsent should replace the null value with the new value
    +    assertNull(
    +        "putIfAbsent(existingKeyWithNullValue, value) should return null",
    +        getMap().putIfAbsent(k0(), v3()));
    +    assertEquals("Map should now contain the new value", v3(), getMap().get(k0()));
    +  }
     }

    view full diff

Rolls-Royce Rolls-Royce

terraform-provider-cscdm, Go
Terraform provider for managing CSC domain registrations and DNS.

  1. Fix: Add HTTP timeout to prevent Terraform from hanging indefinitely
    Added 30-second HTTP request timeout to prevent the Terraform provider from hanging indefinitely when the CSC Domain Manager API accepts connections but doesn't respond.

    +11/-8
    diff --git a/internal/cscdm/cscdm.go b/internal/cscdm/cscdm.go
    index 812162f..58885d7 100644
    --- a/internal/cscdm/cscdm.go
    +++ b/internal/cscdm/cscdm.go
    @@ -15,6 +15,7 @@ const (
     	CSC_DOMAIN_MANAGER_API_URL = "https://apis.cscglobal.com/dbs/api/v2/"
     	POLL_INTERVAL              = 5 * time.Second
     	FLUSH_IDLE_DURATION        = 5 * time.Second
    +	HTTP_REQUEST_TIMEOUT       = 30 * time.Second
     )
    
     type Client struct {
    @@ -36,14 +37,16 @@ type Client struct {
     }
    
     func (c *Client) Configure(apiKey string, apiToken string) {
    -	c.http = &http.Client{Transport: &util.HttpTransport{
    -		BaseUrl: CSC_DOMAIN_MANAGER_API_URL,
    -		Headers: map[string]string{
    -			"accept":        "application/json",
    -			"apikey":        apiKey,
    -			"Authorization": fmt.Sprintf("Bearer %s", apiToken),
    -		},
    -	}}
    +	c.http = &http.Client{
    +		Timeout: HTTP_REQUEST_TIMEOUT,
    +		Transport: &util.HttpTransport{
    +			BaseUrl: CSC_DOMAIN_MANAGER_API_URL,
    +			Headers: map[string]string{
    +				"accept":        "application/json",
    +				"apikey":        apiKey,
    +				"Authorization": fmt.Sprintf("Bearer %s", apiToken),
    +			},
    +		}}
    
     	c.returnChannels = make(map[string]chan *ZoneRecord)
     	c.errorChannels = make(map[string]chan error)

    view full diff

  2. Enhance(error handling): improve flush loop and trigger handling in cscdm
    Replaced sync.Cond with buffered channels to fix goroutine leaks, added sync.Once to prevent panics, and enabled recovery from transient failures instead of permanent termination.

    +483/-19
    diff --git a/internal/cscdm/cscdm.go b/internal/cscdm/cscdm.go
    index 4b28cc4..812162f 100644
    --- a/internal/cscdm/cscdm.go
    +++ b/internal/cscdm/cscdm.go
    @@ -26,8 +26,9 @@ type Client struct {
     	batchMutex          sync.Mutex
     	returnChannelsMutex sync.Mutex
    
    -	flushTrigger      *sync.Cond
    +	flushTrigger      chan struct{}
     	flushLoopStopChan chan struct{}
    +	stopOnce          sync.Once
    
     	zoneCache  map[string]*Zone
     	zoneGroup  singleflight.Group
    @@ -47,7 +48,7 @@ func (c *Client) Configure(apiKey string, apiToken string) {
     	c.returnChannels = make(map[string]chan *ZoneRecord)
     	c.errorChannels = make(map[string]chan error)
    
    -	c.flushTrigger = sync.NewCond(&sync.Mutex{})
    +	c.flushTrigger = make(chan struct{}, 1)
     	c.flushLoopStopChan = make(chan struct{})
    
     	c.zoneCache = make(map[string]*Zone)
    @@ -57,28 +58,24 @@ func (c *Client) Configure(apiKey string, apiToken string) {
    
     func (c *Client) flushLoop() {
     	for {
    -		triggerChan := make(chan struct{})
    -		go func() {
    -			c.flushTrigger.L.Lock()
    -			c.flushTrigger.Wait()
    -			c.flushTrigger.L.Unlock()
    -			close(triggerChan)
    -		}()
    -
     		flushTimer := time.NewTimer(FLUSH_IDLE_DURATION)
    
     		select {
    -		case <-triggerChan:
    +		case <-c.flushTrigger:
     			// Flush triggered; reset flush timer
     			flushTimer.Stop()
    -			continue
    +			// Drain the channel in case of multiple signals
    +			select {
    +			case <-c.flushTrigger:
    +			default:
    +			}
     		case <-flushTimer.C:
     			// Timer expired; flush queue
     			err := c.flush()
    
     			if err != nil {
    -				fmt.Fprintf(os.Stderr, "failed to flush queue: %s", err.Error())
    -				return
    +				fmt.Fprintf(os.Stderr, "failed to flush queue: %s\n", err.Error())
    +				// Continue - don't return/terminate
     			}
     		case <-c.flushLoopStopChan:
     			// Stop flush loop
    @@ -89,12 +86,15 @@ func (c *Client) flushLoop() {
     }
    
     func (c *Client) triggerFlush() {
    -	c.flushTrigger.L.Lock()
    -	defer c.flushTrigger.L.Unlock()
    -
    -	c.flushTrigger.Signal()
    +	// Non-blocking send - if channel full, trigger already pending
    +	select {
    +	case c.flushTrigger <- struct{}{}:
    +	default:
    +	}
     }
    
     func (c *Client) Stop() {
    -	close(c.flushLoopStopChan)
    +	c.stopOnce.Do(func() {
    +		close(c.flushLoopStopChan)
    +	})
     }

    view full diff

Stripe Stripe

stripe-go, Go
Official Go client library for the Stripe payments API.

  • Add context-aware logging interface and update logger usage
    Adds ContextLeveledLoggerInterface to enable distributed tracing integration. Backend checks interface type on each log call and passes context when supported. Fully backward compatible with existing LeveledLoggerInterface implementations. Fixes #1281.

    +536/-19
    diff --git a/log.go b/log.go
    index b52d7ac56b..6f529a5db5 100644
    --- a/log.go
    +++ b/log.go
    @@ -1,6 +1,7 @@
     package stripe
    
     import (
    +	"context"
     	"fmt"
     	"io"
     	"os"
    @@ -140,3 +141,24 @@ type LeveledLoggerInterface interface {
     	// Warnf logs a warning message using Printf conventions.
     	Warnf(format string, v ...interface{})
     }
    +
    +// ContextLeveledLoggerInterface provides a context-aware leveled logging interface
    +// for printing debug, informational, warning, and error messages with access to
    +// the request's context.Context.
    +//
    +// This interface allows loggers to extract trace IDs, request IDs, and other
    +// contextual information for distributed tracing systems like OpenTelemetry,
    +// OpenCensus, or custom correlation tracking.
    +type ContextLeveledLoggerInterface interface {
    +	// Debugf logs a debug message using Printf conventions with context.
    +	Debugf(ctx context.Context, format string, v ...interface{})
    +
    +	// Errorf logs an error message using Printf conventions with context.
    +	Errorf(ctx context.Context, format string, v ...interface{})
    +
    +	// Infof logs an informational message using Printf conventions with context.
    +	Infof(ctx context.Context, format string, v ...interface{})
    +
    +	// Warnf logs a warning message using Printf conventions with context.
    +	Warnf(ctx context.Context, format string, v ...interface{})
    +}
    diff --git a/stripe.go b/stripe.go
    index f1a0111fb2..ee3334231f 100644
    --- a/stripe.go
    +++ b/stripe.go
    @@ -228,6 +228,11 @@ type BackendConfig struct {
     	// LeveledLogger is the logger that the backend will use to log errors,
     	// warnings, and informational messages.
     	//
    +	// This field accepts either LeveledLoggerInterface (the traditional interface)
    +	// or ContextLeveledLoggerInterface (the context-aware interface). The SDK will
    +	// automatically detect which interface your logger implements and use it
    +	// appropriately.
    +	//
     	// LeveledLoggerInterface is implemented by LeveledLogger, and one can be
     	// initialized at the desired level of logging.  LeveledLoggerInterface
     	// also provides out-of-the-box compatibility with a Logrus Logger, but may
    @@ -239,7 +244,7 @@ type BackendConfig struct {
     	// To set a logger that logs nothing, set this to a stripe.LeveledLogger
     	// with a Level of LevelNull (simply setting this field to nil will not
     	// work).
    -	LeveledLogger LeveledLoggerInterface
    +	LeveledLogger interface{}
    
     	// MaxNetworkRetries sets maximum number of times that the library will
     	// retry requests that appear to have failed due to an intermittent
    @@ -653,6 +662,42 @@ func (s *BackendImplementation) maybeEnqueueTelemetryMetrics(requestID string, r
     	}
     }
    
    +// logDebugf logs a debug message, using context-aware logger if available
    +func (s *BackendImplementation) logDebugf(ctx context.Context, format string, v ...interface{}) {
    +	if logger, ok := s.LeveledLogger.(ContextLeveledLoggerInterface); ok {
    +		logger.Debugf(ctx, format, v...)
    +	} else if logger, ok := s.LeveledLogger.(LeveledLoggerInterface); ok {
    +		logger.Debugf(format, v...)
    +	}
    +}
    +
    +// logInfof logs an info message, using context-aware logger if available
    +func (s *BackendImplementation) logInfof(ctx context.Context, format string, v ...interface{}) {
    +	if logger, ok := s.LeveledLogger.(ContextLeveledLoggerInterface); ok {
    +		logger.Infof(ctx, format, v...)
    +	} else if logger, ok := s.LeveledLogger.(LeveledLoggerInterface); ok {
    +		logger.Infof(format, v...)
    +	}
    +}
    +
    +// logWarnf logs a warning message, using context-aware logger if available
    +func (s *BackendImplementation) logWarnf(ctx context.Context, format string, v ...interface{}) {
    +	if logger, ok := s.LeveledLogger.(ContextLeveledLoggerInterface); ok {
    +		logger.Warnf(ctx, format, v...)
    +	} else if logger, ok := s.LeveledLogger.(LeveledLoggerInterface); ok {
    +		logger.Warnf(format, v...)
    +	}
    +}
    +
    +// logErrorf logs an error message, using context-aware logger if available
    +func (s *BackendImplementation) logErrorf(ctx context.Context, format string, v ...interface{}) {
    +	if logger, ok := s.LeveledLogger.(ContextLeveledLoggerInterface); ok {
    +		logger.Errorf(ctx, format, v...)
    +	} else if logger, ok := s.LeveledLogger.(LeveledLoggerInterface); ok {
    +		logger.Errorf(format, v...)
    +	}
    +}
    +
     func resetBodyReader(body *bytes.Buffer, req *http.Request) {
     	// This might look a little strange, but we set the request's body
     	// outside of `NewRequest` so that we can get a fresh version every

    view full diff

pg-schema-diff, Go
Tool for generating safe PostgreSQL schema migrations by diffing schemas.

  • Fix: Support GENERATED ALWAYS AS columns to reduce migration failures
    Fixed migration failures where generated columns were incorrectly treated as DEFAULT columns. Updated schema introspection to detect pg_attribute.attgenerated, extended the Column model, and fixed DDL generation to output proper GENERATED ALWAYS AS ... STORED syntax.

    +275/-37
    diff --git a/internal/migration_acceptance_tests/column_cases_test.go b/internal/migration_acceptance_tests/column_cases_test.go
    index 19e3852..a6c16c6 100644
    --- a/internal/migration_acceptance_tests/column_cases_test.go
    +++ b/internal/migration_acceptance_tests/column_cases_test.go
    @@ -1184,6 +1184,149 @@ var columnAcceptanceTestCases = []acceptanceTestCase{
     			`,
     		},
     	},
    +	{
    +		name: "Add generated column",
    +		oldSchemaDDL: []string{
    +			`
                CREATE TABLE tabs (
                    id SERIAL PRIMARY KEY,
                    title TEXT NOT NULL,
                    artist TEXT
                );
    +			`,
    +		},
    +		newSchemaDDL: []string{
    +			`
                CREATE TABLE tabs (
                    id SERIAL PRIMARY KEY,
                    title TEXT NOT NULL,
                    artist TEXT,
                    search_vector tsvector GENERATED ALWAYS AS (
                        to_tsvector('simple', title || ' ' || coalesce(artist, ''))
                    ) STORED
                );
    +			`,
    +		},
    +	},
    +	{
    +		name: "Drop generated column",
    +		oldSchemaDDL: []string{
    +			`
                CREATE TABLE tabs (
                    id SERIAL PRIMARY KEY,
                    title TEXT NOT NULL,
                    artist TEXT,
                    search_vector tsvector GENERATED ALWAYS AS (
                        to_tsvector('simple', title || ' ' || coalesce(artist, ''))
                    ) STORED
                );
    +			`,
    +		},
    +		newSchemaDDL: []string{
    +			`
                CREATE TABLE tabs (
                    id SERIAL PRIMARY KEY,
                    title TEXT NOT NULL,
                    artist TEXT
                );
    +			`,
    +		},
    +		expectedHazardTypes: []diff.MigrationHazardType{
    +			diff.MigrationHazardTypeDeletesData,
    +		},
    +	},
    +	// ... more test cases
     }
    diff --git a/internal/queries/queries.sql b/internal/queries/queries.sql
    index 5889b65..bc0c734 100644
    --- a/internal/queries/queries.sql
    +++ b/internal/queries/queries.sql
    @@ -89,11 +89,6 @@ WITH identity_col_seq AS (
    
     SELECT
         a.attname::TEXT AS column_name,
    -    COALESCE(coll.collname, '')::TEXT AS collation_name,
    -    COALESCE(collation_namespace.nspname, '')::TEXT AS collation_schema_name,
    -    COALESCE(
    -        pg_catalog.pg_get_expr(d.adbin, d.adrelid), ''
    -    )::TEXT AS default_value,
         a.attnotnull AS is_not_null,
         a.attlen AS column_size,
         a.attidentity::TEXT AS identity_type,
    @@ -103,6 +98,22 @@ SELECT
         identity_col_seq.seqmin AS min_value,
         identity_col_seq.seqcache AS cache_size,
         identity_col_seq.seqcycle AS is_cycle,
    +    COALESCE(coll.collname, '')::TEXT AS collation_name,
    +    COALESCE(collation_namespace.nspname, '')::TEXT AS collation_schema_name,
    +    COALESCE(
    +        CASE
    +            WHEN a.attgenerated = 's' THEN ''
    +            ELSE pg_catalog.pg_get_expr(d.adbin, d.adrelid)
    +        END, ''
    +    )::TEXT AS default_value,
    +    COALESCE(
    +        CASE
    +            WHEN a.attgenerated = 's'
    +                THEN pg_catalog.pg_get_expr(d.adbin, d.adrelid)
    +            ELSE ''
    +        END, ''
    +    )::TEXT AS generation_expression,
    +    (a.attgenerated = 's') AS is_generated,
         pg_catalog.format_type(a.atttypid, a.atttypmod) AS column_type
     FROM pg_catalog.pg_attribute AS a
    diff --git a/internal/schema/schema.go b/internal/schema/schema.go
    index 3f8b294..33cdc3a 100644
    --- a/internal/schema/schema.go
    +++ b/internal/schema/schema.go
    @@ -264,8 +264,16 @@ type (
     		//   ''::text
     		//   CURRENT_TIMESTAMP
     		// If empty, indicates that there is no default value.
    -		Default    string
    -		IsNullable bool
    +		Default string
    +		// If the column is a generated column, this will be true.
    +		IsGenerated bool
    +		// If the column is a generated column, this will be the generation expression.
    +		// Examples:
    +		//   to_tsvector('simple', title || ' ' || coalesce(artist, ''))
    +		//   (price * 1.1)
    +		// Only populated if IsGenerated is true.
    +		GenerationExpression string
    +		IsNullable           bool
    diff --git a/pkg/diff/sql_generator.go b/pkg/diff/sql_generator.go
    index 54d8ef1..40228a6 100644
    --- a/pkg/diff/sql_generator.go
    +++ b/pkg/diff/sql_generator.go
    @@ -2637,12 +2637,14 @@ func buildColumnDefinition(column schema.Column) (string, error) {
     	if column.IsCollated() {
     		sb.WriteString(fmt.Sprintf(" COLLATE %s", column.Collation.GetFQEscapedName()))
     	}
    +	if column.IsGenerated {
    +		sb.WriteString(fmt.Sprintf(" GENERATED ALWAYS AS (%s) STORED", column.GenerationExpression))
    +	} else if len(column.Default) > 0 {
    +		sb.WriteString(fmt.Sprintf(" DEFAULT %s", column.Default))
    +	}
     	if !column.IsNullable {
     		sb.WriteString(" NOT NULL")
     	}
    -	if len(column.Default) > 0 {
    -		sb.WriteString(fmt.Sprintf(" DEFAULT %s", column.Default))
    -	}

    view full diff

GoCardless GoCardless

woocommerce-gateway, PHP
Payment gateway plugin connecting WooCommerce stores to GoCardless Direct Debit.

  • Fix inconsistent subscription status after cancellation with centralized cancellation logic
    Fixed subscription status incorrectly showing "Pending Cancellation" instead of "Cancelled" when users cancel before GoCardless payment confirmation. Added centralized cancellation handling with parent order status synchronization.

    +81/-0
    diff --git a/includes/class-wc-gocardless-gateway-addons.php b/includes/class-wc-gocardless-gateway-addons.php
    index e490cb7..4c93110 100644
    --- a/includes/class-wc-gocardless-gateway-addons.php
    +++ b/includes/class-wc-gocardless-gateway-addons.php
    @@ -33,6 +33,9 @@ public function __construct() {
     			// Cancel in-progress payment on subscription cancellation.
     			add_action( 'woocommerce_subscription_pending-cancel_' . $this->id, array( $this, 'maybe_cancel_subscription_payment' ) );
     			add_action( 'woocommerce_subscription_cancelled_' . $this->id, array( $this, 'maybe_cancel_subscription_payment' ) );
    +
    +			// Status synchronization for parent orders.
    +			add_action( 'woocommerce_subscription_status_updated', array( $this, 'sync_parent_order_status' ), 10, 3 );
     		}
    
     		if ( class_exists( 'WC_Pre_Orders_Order' ) ) {
    @@ -40,6 +43,84 @@ public function __construct() {
     		}
     	}
    
    +	/**
    +	 * Synchronize parent order status when all subscriptions are cancelled.
    +	 * Also intercepts pending-cancel transitions for subscriptions with unconfirmed payments,
    +	 * checking the most recent order (parent or renewal) to determine if payment is confirmed.
    +	 *
    +	 * @since x.x.x
    +	 * @param WC_Subscription $subscription The subscription object.
    +	 * @param string          $new_status   The new subscription status.
    +	 * @param string          $old_status   The old subscription status.
    +	 */
    +	public function sync_parent_order_status( $subscription, $new_status, $old_status ) {
    +		// Only process GoCardless subscriptions and subscription cancellation triggered by the customer.
    +		if ( $this->id !== $subscription->get_payment_method() || ! isset( $_GET['change_subscription_to'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
    +			return;
    +		}
    +
    +		// Only process Some status -> 'pending-cancel' transition.
    +		if ( 'pending-cancel' !== $new_status || 'pending-cancel' === $old_status ) {
    +			return;
    +		}
    +
    +		/*
    +		 * Handle transition to pending-cancel status for unconfirmed payments.
    +		 * This checks the most recent order's payment status (parent or latest renewal) from the
    +		 * GoCardless API to avoid edge cases with webhook delays.
    +		 */
    +
    +		// Get the most recent order of the subscription.
    +		$last_order = is_callable( array( $subscription, 'get_last_order' ) )
    +			? $subscription->get_last_order( 'all' )
    +			: $subscription->get_parent();
    +
    +		// If the last order is not a valid order, return.
    +		if ( ! $last_order || ! is_a( $last_order, 'WC_Abstract_Order' ) ) {
    +			return;
    +		}
    +
    +		// Get payment status from GoCardless.
    +		$payment_id     = $this->get_order_resource( $last_order->get_id(), 'payment', 'id' );
    +		$payment_status = '';
    +
    +		if ( $payment_id ) {
    +			$payment = WC_GoCardless_API::get_payment( $payment_id );
    +
    +			if ( is_wp_error( $payment ) || empty( $payment['payments'] ) ) {
    +				wc_gocardless()->log(
    +					sprintf(
    +						'%s - Failed to retrieve payment for order #%s',
    +						__METHOD__,
    +						$last_order->get_id()
    +					)
    +				);
    +			} else {
    +				$payment_status = $payment['payments']['status'] ?? '';
    +			}
    +		}
    +
    +		// Payment confirmed statuses that indicate payment has gone through.
    +		$confirmed_statuses = array( 'confirmed', 'paid_out' );
    +
    +		// If payment is not confirmed, cancel immediately.
    +		if ( ! in_array( $payment_status, $confirmed_statuses, true ) ) {
    +			wc_gocardless()->log(
    +				sprintf(
    +					'%s - Cancelling subscription #%s immediately (order #%s has unconfirmed payment status: %s)',
    +					__METHOD__,
    +					$subscription->get_id(),
    +					$last_order->get_id(),
    +					$payment_status ? $payment_status : 'none'
    +				)
    +			);
    +			$subscription->update_status(
    +				'cancelled',
    +				__( 'Subscription cancelled immediately as payment not confirmed for the last order.', 'woocommerce-gateway-gocardless' )
    +			);
    +		}
    +	}
    +
     	/**
     	 * Update GoCardless resource in order meta.
     	 *

    view full diff

Microsoft Microsoft

TypeAgent, TypeScript
Microsoft's AI agent framework for natural language task automation.

  • Return undefined instead of invalid action names for partial matches
    Prevented exceptions when typing partial cached commands by returning undefined instead of invalid "unknown.unknown" action names, enabling graceful handling of partial matches.

    +10/-10
    diff --git a/ts/packages/cache/src/constructions/constructionValue.ts b/ts/packages/cache/src/constructions/constructionValue.ts
    index 0e5253918..f38ed2cf2 100644
    --- a/ts/packages/cache/src/constructions/constructionValue.ts
    +++ b/ts/packages/cache/src/constructions/constructionValue.ts
    @@ -184,7 +184,7 @@ export function createActionProps(
    
         if (actionProps === undefined) {
             if (partial) {
    -            return { fullActionName: "unknown.unknown" };
    +            return { fullActionName: undefined }; // Return undefined for partial matches
             }
             throw new Error(
                 "Internal error: No values provided for action properties",
    @@ -194,19 +194,17 @@ export function createActionProps(
         if (Array.isArray(actionProps)) {
             actionProps.forEach((actionProp) => {
                 if (actionProp.fullActionName === undefined) {
    -                if (partial) {
    -                    actionProp.fullActionName = "unknown.unknown";
    -                } else {
    +                if (!partial) {
                         throw new Error("Internal error: fullActionName missing");
                     }
    +                // Leave undefined for partial matches
                 }
             });
         } else if (actionProps.fullActionName === undefined) {
    -        if (partial) {
    -            actionProps.fullActionName = "unknown.unknown";
    -        } else {
    +        if (!partial) {
                 throw new Error("Internal error: fullActionName missing");
             }
    +        // Leave undefined for partial matches
         }
    
         return actionProps;
    diff --git a/ts/packages/cache/src/explanation/requestAction.ts b/ts/packages/cache/src/explanation/requestAction.ts
    index 14560c99d..a5f26eba3 100644
    --- a/ts/packages/cache/src/explanation/requestAction.ts
    +++ b/ts/packages/cache/src/explanation/requestAction.ts
    @@ -194,9 +194,11 @@ function executableActionsToString(actions: ExecutableAction[]): string {
     }
    
     function fromJsonAction(actionJSON: JSONAction) {
    -    const { schemaName, actionName } = parseFullActionNameParts(
    -        actionJSON.fullActionName,
    -    );
    +    const { schemaName, actionName } =
    +        actionJSON.fullActionName !== undefined
    +            ? parseFullActionNameParts(actionJSON.fullActionName)
    +            : { schemaName: undefined as any, actionName: undefined as any };
    +
         return createExecutableAction(
             schemaName,
             actionName,

    view full diff

Penpot Penpot

penpot, Clojure and SQL
Open-source design and prototyping platform (alternative to Figma).

Penpot milestone lock feature

  • ✨ Enhance (version control): Add milestone lock feature to prevent accidental deletion and bad actor interventions
    Implemented version locking system allowing users to protect saved milestones from accidental deletion or bad actors. Added database migration, RPC endpoints with authorization, and UI with visual lock indicators.

    +292/-17
    diff --git a/backend/src/app/migrations.clj b/backend/src/app/migrations.clj
    index 795a9bea5c1..9c40bc884ce 100644
    --- a/backend/src/app/migrations.clj
    +++ b/backend/src/app/migrations.clj
    @@ -438,7 +438,10 @@
         {:name "0139-mod-file-change-table.sql"
    -    :fn (mg/resource "app/migrations/sql/0139-mod-file-change-table.sql")}])
    +    :fn (mg/resource "app/migrations/sql/0139-mod-file-change-table.sql")}
    +
    +   {:name "0140-add-locked-by-column-to-file-change-table"
    +    :fn (mg/resource "app/migrations/sql/0140-add-locked-by-column-to-file-change-table.sql")}])
    
    diff --git a/backend/src/app/migrations/sql/0140-add-locked-by-column-to-file-change-table.sql b/backend/src/app/migrations/sql/0140-add-locked-by-column-to-file-change-table.sql
    new file mode 100644
    index 00000000000..d9052b105fe
    --- /dev/null
    +++ b/backend/src/app/migrations/sql/0140-add-locked-by-column-to-file-change-table.sql
    @@ -0,0 +1,11 @@
    +-- Add locked_by column to file_change table for version locking feature
    +-- This allows users to lock their own saved versions to prevent deletion by others
    +
    +ALTER TABLE file_change
    +  ADD COLUMN locked_by uuid NULL REFERENCES profile(id) ON DELETE SET NULL DEFERRABLE;
    +
    +-- Create index for locked versions queries
    +CREATE INDEX file_change__locked_by__idx ON file_change (locked_by) WHERE locked_by IS NOT NULL;
    +
    +-- Add comment for documentation
    +COMMENT ON COLUMN file_change.locked_by IS 'Profile ID of user who has locked this version.';
    
    diff --git a/backend/src/app/rpc/commands/files_snapshot.clj b/backend/src/app/rpc/commands/files_snapshot.clj
    index 71689560a51..32c128af2bf 100644
    --- a/backend/src/app/rpc/commands/files_snapshot.clj
    +++ b/backend/src/app/rpc/commands/files_snapshot.clj
    @@ -29,7 +29,7 @@
    
     (def sql:get-file-snapshots
       "WITH changes AS (
    -      SELECT id, label, revn, created_at, created_by, profile_id
    +      SELECT id, label, revn, created_at, created_by, profile_id, locked_by
           FROM file_change
           WHERE file_id = ?
             AND data IS NOT NULL
    
    +;;; Lock/unlock version endpoints
    +
    +(defn- lock-file-snapshot!
    +  [conn snapshot-id profile-id]
    +  (db/update! conn :file-change
    +              {:locked-by profile-id}
    +              {:id snapshot-id}
    +              {::db/return-keys false})
    +  nil)
    +
    +(sv/defmethod ::lock-file-snapshot
    +  {::doc/added "1.20"
    +   ::sm/params schema:lock-file-snapshot}
    +  [cfg {:keys [::rpc/profile-id id]}]
    +  (db/tx-run! cfg
    +              (fn [{:keys [::db/conn]}]
    +                (let [snapshot (get-snapshot conn id)]
    +                  (files/check-edition-permissions! conn profile-id (:file-id snapshot))
    +
    +                  ;; Only the creator can lock their own version
    +                  (when (not= (:profile-id snapshot) profile-id)
    +                    (ex/raise :type :validation
    +                              :code :only-creator-can-lock
    +                              :hint "Only the version creator can lock it"))
    +
    +                  (lock-file-snapshot! conn id profile-id)))))
    +
    +(sv/defmethod ::unlock-file-snapshot
    +  {::doc/added "1.20"
    +   ::sm/params schema:unlock-file-snapshot}
    +  [cfg {:keys [::rpc/profile-id id]}]
    +  (db/tx-run! cfg
    +              (fn [{:keys [::db/conn]}]
    +                (let [snapshot (get-snapshot conn id)]
    +                  (files/check-edition-permissions! conn profile-id (:file-id snapshot))
    +
    +                  ;; Only the creator can unlock their own version
    +                  (when (not= (:profile-id snapshot) profile-id)
    +                    (ex/raise :type :validation
    +                              :code :only-creator-can-unlock
    +                              :hint "Only the version creator can unlock it"))
    +
    +                  (unlock-file-snapshot! conn id)))))
    
    diff --git a/frontend/src/app/main/data/workspace/versions.cljs b/frontend/src/app/main/data/workspace/versions.cljs
    index f2ae3bb3a70..d38d9b195e6 100644
    --- a/frontend/src/app/main/data/workspace/versions.cljs
    +++ b/frontend/src/app/main/data/workspace/versions.cljs
    @@ -148,6 +148,29 @@
    
    +(defn lock-version
    +  [id]
    +  (assert (uuid? id) "expected valid uuid for `id`")
    +  (ptk/reify ::lock-version
    +    ptk/WatchEvent
    +    (watch [_ _ _]
    +      (->> (rp/cmd! :lock-file-snapshot {:id id})
    +           (rx/map fetch-versions)))))
    +
    +(defn unlock-version
    +  [id]
    +  (assert (uuid? id) "expected valid uuid for `id`")
    +  (ptk/reify ::unlock-version
    +    ptk/WatchEvent
    +    (watch [_ _ _]
    +      (->> (rp/cmd! :unlock-file-snapshot {:id id})
    +           (rx/map fetch-versions)))))
    
    diff --git a/frontend/src/app/main/ui/ds/product/user_milestone.cljs b/frontend/src/app/main/ui/ds/product/user_milestone.cljs
    --- a/frontend/src/app/main/ui/ds/product/user_milestone.cljs
    +++ b/frontend/src/app/main/ui/ds/product/user_milestone.cljs
    @@ -25,6 +26,7 @@
        [:class {:optional true} :string]
        [:active {:optional true} :boolean]
        [:editing {:optional true} :boolean]
    +   [:locked {:optional true} :boolean]
        [:user
         [:map
          [:name {:optional true} [:maybe :string]]
    
    +       [:div {:class (stl/css :name-wrapper)}
    +        [:> text*  {:as "span" :typography t/body-small :class (stl/css :name)} label]
    +        (when locked
    +          [:> i/icon* {:icon-id i/lock :class (stl/css :lock-icon)}])])
    
    diff --git a/frontend/translations/en.po b/frontend/translations/en.po
    +msgid "labels.lock"
    +msgstr "Lock"
    +
    +msgid "labels.unlock"
    +msgstr "Unlock"
    +
    +msgid "errors.version-locked"
    +msgstr "This version is locked and cannot be deleted by others"
    +
    +msgid "errors.only-creator-can-lock"
    +msgstr "Only the version creator can lock it"

    view full diff

Commercial Projects

Currently in Research and Development, details to follow.

Pinned Loading

  1. developer-portfolio developer-portfolio Public

    A dynamic portfolio website harnessing a github API that pulls data during the CI pipeline for optimal speed. Deployment branching for 10x-friendly review process.

    TypeScript 1

  2. dottie dottie Public

    Chatbot that interprets user datasets on SQL with Gemini API.

    TypeScript 44 61

  3. bucket-harvest bucket-harvest Public

    harvests repositories and tickets in the open source ecosystem

    Python

  4. fat-tony fat-tony Public

    API for integrating trading 212 to Google Docs

    Python

  5. Steam-Report Steam-Report Public

    A hybrid of Personality Trait Test and Trivia quiz that provides young people with a recommended career pathway based on their unique traits and subject knowledge

    Python 2 1