Skip to content

feat(db): partial AND index optimization via residual predicate#1562

Open
v-anton wants to merge 5 commits into
TanStack:mainfrom
v-anton:feat/partial-and-residual-predicate
Open

feat(db): partial AND index optimization via residual predicate#1562
v-anton wants to merge 5 commits into
TanStack:mainfrom
v-anton:feat/partial-and-residual-predicate

Conversation

@v-anton
Copy link
Copy Markdown

@v-anton v-anton commented May 28, 2026

🎯 Changes

Stacks on #1550 — that PR should land first; the OR-strictness commits show in this diff until then.

Fixes a soundness bug in optimizeAndExpression where partial AND optimization returned the intersection of only the index-optimizable branches and the caller treated those keys as exact matches, silently dropping the constraint from unindexed branches. The bug was masked today by the existing partial-AND test only because the test data had a single row per age value.

The fix adds candidate-set semantics: indexed branches narrow the candidate set; unindexed branches are re-evaluated as a residual predicate against that set. Standard query-optimizer pattern, and for the in-memory case it preserves the index speedup almost entirely.

API change — additive, non-breaking:

export interface OptimizationResult<TKey> {
  canOptimize: boolean
  matchingKeys: Set<TKey>
  residualPredicate?: BasicExpression // new
}

When residualPredicate is set, the caller MUST evaluate it against each matchingKeys entry and drop rows that fail. When undefined, matchingKeys is exact.

Optimizer:

  • AND — partitions branches into optimizable / non-optimizable. matchingKeys = intersection of optimizable branches' key sets. Residual = and(...childResiduals, ...nonOptimizableBranches) (or the single element directly, or undefined).
  • Compound range — when optimizeCompoundRangeQuery fuses range ops on one field inside a larger AND, the args it consumed are tracked, and the remaining AND args participate in the residual. Previously the compound-range path short-circuited and bypassed residual composition entirely (e.g. and(gte(age, 18), lte(age, 65), eq(name, 'Alice')) silently dropped the name constraint).
  • OR — strict: every branch must be fully optimizable, no residual. A new canFullyOptimizeExpression helper expresses the strict-OR contract (an AND child must have all branches optimizable, not just one); canOptimizeOrExpression and optimizeOrExpression both use it. The runtime path now short-circuits up front via this predicate, so no wasted index lookups occur when the OR will ultimately reject — strict-OR tests assert shouldUseIndex: false, fullScanCallCount: 1.
  • Leaf optimizers unchanged; never produce a residual.

Caller (currentStateAsChanges) builds a filter from residualPredicate (if any) and drops candidates that fail it.

Why this shape for an in-memory DB: dominant cost is per-row JS predicate evaluation, not I/O. Index lookups against in-memory Map/BTree are cheap enough that taking the full intersection up front is fine — no cost-based "most-selective driver" needed. The saving comes from skipping residual evaluation on the N − |matchingKeys| rows the index already eliminated. For OR with a non-indexed branch, you'd still have to scan the complement to find matches, so partial OR is intentionally out of scope.

Benchmark (tests/index-optimization.bench.ts, 10k rows, and(eq(age, 25), eq(name, 'user-2500'))):

Scenario ops/sec vs full scan
Full scan (no indexes) 142
Partial index (age only, name as residual) 17,755 ~125× faster
Full index (age + name) 289,535 ~2,046× faster

Tests (added to tests/collection-indexes.test.ts): hardened soundness for partial AND, multi-arity AND, compound-range with non-range residual, nested OR-inside-AND with residual, strict-OR rejecting both leaf-unindexed and AND-with-residual children (both assert no index calls + 1 full scan), and a regression guard that fully indexed AND produces no residual. All 2379 tests pass (5 skipped); 0 lint errors; types clean.

✅ Checklist

  • I have tested this code locally with pnpm test.

🚀 Release Impact

  • This change affects published code, and I have generated a changeset.
  • This change is docs/CI/dev-only (no release).

Summary by CodeRabbit

  • Bug Fixes

    • Query results now correctly re-filter candidates when only part of an AND can use indexes, preventing over-inclusive results.
    • OR optimization now requires all branches to be fully index-optimizable, avoiding incorrect partial optimizations.
    • Change events now re-evaluate residual predicates before emitting inserts to ensure emitted rows truly match.
  • Tests

    • Added regression tests and benchmarks covering mixed indexed/unindexed query scenarios and performance.

Review Change Stack

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 28, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ccb67a11-0d09-4eec-99d0-214295668c73

📥 Commits

Reviewing files that changed from the base of the PR and between 3b47dbd and ca2128b.

📒 Files selected for processing (5)
  • .changeset/partial-and-residual-predicate.md
  • packages/db/src/collection/change-events.ts
  • packages/db/src/utils/index-optimization.ts
  • packages/db/tests/collection-indexes.test.ts
  • packages/db/tests/index-optimization.bench.ts
✅ Files skipped from review due to trivial changes (2)
  • packages/db/tests/index-optimization.bench.ts
  • .changeset/partial-and-residual-predicate.md
🚧 Files skipped from review as they are similar to previous changes (3)
  • packages/db/src/utils/index-optimization.ts
  • packages/db/src/collection/change-events.ts
  • packages/db/tests/collection-indexes.test.ts

📝 Walkthrough

Walkthrough

Adds optional residual predicates to index optimization results, partitions AND branches into indexed vs residual parts, tightens OR to require fully-optimizable branches, rechecks residual predicates at emission time, and adds tests/benchmarks plus changesets.

Changes

Index Optimization Soundness

Layer / File(s) Summary
OptimizationResult contract
packages/db/src/utils/index-optimization.ts
OptimizationResult gains an optional residualPredicate field that callers must evaluate against matchingKeys when present.
AND expression optimization & compound-range fusion
packages/db/src/utils/index-optimization.ts
optimizeAndExpression partitions children, intersects indexed matchingKeys, fuses compound ranges (reporting consumed args), and folds remaining args/residuals into a single residualPredicate when optimization is partial.
OR expression strict semantics
packages/db/src/utils/index-optimization.ts
optimizeOrExpression and canOptimizeOrExpression now require every OR branch be fully optimizable (no residuals); otherwise OR optimization is rejected and falls back to full scan.
Residual predicate application in execution
packages/db/src/collection/change-events.ts
currentStateAsChanges compiles and applies residualPredicate to each candidate row returned from index optimization before emitting insert events.
Regression & unit tests
packages/db/tests/collection-indexes.test.ts, packages/db/tests/query/or-partial-optimization.test.ts
New tests validate residual re-filtering for partial ANDs, correct hoisting/hoisted OR behavior, strict-OR fallback, fully-indexed AND regression, and soundness after compound-range optimization.
AND query optimization benchmark
packages/db/tests/index-optimization.bench.ts
Benchmarks currentStateAsChanges across no-index, partial-index, and full-index collection variants with pre-benchmark soundness checks.
Release changesets
.changeset/*
Adds changesets documenting the OR partial-union fix and the AND residual-predicate soundness fix.

Sequence Diagram(s)

sequenceDiagram
  participant Query as Query (where)
  participant Optimizer as optimizeAndExpression
  participant Store as Collection Store
  participant Evaluator as residualPredicate evaluator
  participant Emitter as currentStateAsChanges
  Query->>Optimizer: produce matchingKeys + optional residualPredicate
  Optimizer-->>Query: matchingKeys + residualPredicate
  Query->>Store: fetch values for matchingKeys
  Store-->>Query: candidate rows
  Query->>Evaluator: apply residualPredicate to candidates
  Evaluator-->>Query: validated candidates
  Query->>Emitter: emit insert for validated candidates
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related issues

Suggested reviewers

  • samwillis

Poem

🐰 I hopped through indexes, found residual leaves,

AND branches trimmed, then rechecked what deceives.
OR would not yield unless each branch aligned,
Candidates filtered — correct rows we find.
A cheerful rabbit celebrates the refined index. 🥕

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 70.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: introducing partial AND index optimization via a residual predicate to fix a soundness bug.
Description check ✅ Passed The description is comprehensive and well-structured, covering changes, testing, and release impact with both checklist items completed.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@v-anton v-anton force-pushed the feat/partial-and-residual-predicate branch from 8a856f8 to 292bacc Compare May 28, 2026 08:02
@v-anton v-anton marked this pull request as ready for review May 28, 2026 08:04
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (2)
packages/db/tests/collection-indexes.test.ts (1)

1095-1131: ⚡ Quick win

Assert strict-OR fallback behavior explicitly, not only result correctness.

These tests prove row correctness, but they don’t prove the optimizer rejected partial OR optimization. Please wrap strict-OR cases with withIndexTracking(...) and assert a full-scan path (shouldUseIndex: false, shouldUseFullScan: true) to lock in the intended contract.

Suggested tightening pattern
-    it(`should reject partial optimization inside OR (strict OR)`, () => {
-      const result = collection.currentStateAsChanges({
-        where: or(
-          eq(new PropRef([`status`]), `active`),
-          eq(new PropRef([`name`]), `Alice`),
-        ),
-      })!
+    it(`should reject partial optimization inside OR (strict OR)`, () => {
+      withIndexTracking(collection, (tracker) => {
+        const result = collection.currentStateAsChanges({
+          where: or(
+            eq(new PropRef([`status`]), `active`),
+            eq(new PropRef([`name`]), `Alice`),
+          ),
+        })!

-      expect(result).toHaveLength(3)
-      const names = result.map((r) => r.value.name).sort()
-      expect(names).toEqual([`Alice`, `Charlie`, `Eve`])
+        expect(result).toHaveLength(3)
+        const names = result.map((r) => r.value.name).sort()
+        expect(names).toEqual([`Alice`, `Charlie`, `Eve`])
+        expectIndexUsage(tracker.stats, {
+          shouldUseIndex: false,
+          shouldUseFullScan: true,
+          indexCallCount: 0,
+          fullScanCallCount: 1,
+        })
+      })
     })

As per coding guidelines, "**/*.test.{ts,tsx,js}: Always add unit tests that reproduce a bug before fixing it to ensure the bug is fixed and prevent regression".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/db/tests/collection-indexes.test.ts` around lines 1095 - 1131, Wrap
the two strict-OR tests that currently call
collection.currentStateAsChanges(...) inside withIndexTracking(...) and assert
the optimizer chose a full-scan path by checking the tracked plan fields (e.g.,
shouldUseIndex: false and shouldUseFullScan: true) so the tests verify the
optimizer rejected partial OR optimization; locate the calls using symbols
collection.currentStateAsChanges, or, and, eq, PropRef and replace/augment the
test bodies to call withIndexTracking(() =>
collection.currentStateAsChanges(...)) and assert the tracking result contains
shouldUseIndex:false and shouldUseFullScan:true in addition to the existing row
assertions.
packages/db/tests/index-optimization.bench.ts (1)

86-86: ⚡ Quick win

Remove non-null assertion and handle undefined explicitly.

currentStateAsChanges() can return undefined when optimizedOnly: true and optimization fails. While this benchmark doesn't set that option, the ! assertion masks potential issues and reduces defensive coding.

🛡️ Proposed fix for explicit undefined handling
-  const result = col.currentStateAsChanges({ where: whereExpr })!
+  const result = col.currentStateAsChanges({ where: whereExpr })
+  if (!result) {
+    throw new Error(
+      `Bench setup soundness check failed for ${label}: ` +
+        `currentStateAsChanges returned undefined`,
+    )
+  }
   if (result.length !== expectedMatches) {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/db/tests/index-optimization.bench.ts` at line 86, Remove the
non-null assertion on the result of col.currentStateAsChanges({ where: whereExpr
}) and explicitly handle the undefined case: call currentStateAsChanges without
the trailing "!", assign to a const (e.g., result), then check if result ===
undefined and handle it appropriately for the benchmark (throw an error, fail
the test, or skip the benchmark with a clear message). This change should
reference the col.currentStateAsChanges call and the result variable so the code
defensively handles the optimizedOnly failure path instead of masking it.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/db/src/utils/index-optimization.ts`:
- Around line 433-465: The compound-range optimization path currently returns
only matchingKeys when optimizeCompoundRangeQuery(...) succeeds, allowing
non-range conjuncts to be bypassed; modify the success branch of
optimizeCompoundRangeQuery in optimizeQueryRecursive (and related callers) to
also compute and return a residualPredicate consisting of any
non-range/unevaluated child expressions (folding in partial residuals from
optimized children), e.g., build a residual from remaining branches (single
branch as-is or new Func('and', ...)) and include it in the returned object
alongside matchingKeys so mixed range+non-range ANDs are re-evaluated.
- Around line 522-523: canOptimizeOrExpression currently uses
canOptimizeExpression(arg) for each child which incorrectly returns true for
partial-AND children; change canOptimizeOrExpression so it also rejects any
child that can produce a residual. Concretely, keep the existing
canOptimizeExpression(arg) check but add a guard that if arg.kind === 'and' (or
use canOptimizeAndExpression) you require all AND branches to be optimizable
(not just one); i.e., reject the OR if any child is a partial AND or otherwise
can produce residuals so canOptimizeOrExpression and optimizeOrExpression remain
consistent.

---

Nitpick comments:
In `@packages/db/tests/collection-indexes.test.ts`:
- Around line 1095-1131: Wrap the two strict-OR tests that currently call
collection.currentStateAsChanges(...) inside withIndexTracking(...) and assert
the optimizer chose a full-scan path by checking the tracked plan fields (e.g.,
shouldUseIndex: false and shouldUseFullScan: true) so the tests verify the
optimizer rejected partial OR optimization; locate the calls using symbols
collection.currentStateAsChanges, or, and, eq, PropRef and replace/augment the
test bodies to call withIndexTracking(() =>
collection.currentStateAsChanges(...)) and assert the tracking result contains
shouldUseIndex:false and shouldUseFullScan:true in addition to the existing row
assertions.

In `@packages/db/tests/index-optimization.bench.ts`:
- Line 86: Remove the non-null assertion on the result of
col.currentStateAsChanges({ where: whereExpr }) and explicitly handle the
undefined case: call currentStateAsChanges without the trailing "!", assign to a
const (e.g., result), then check if result === undefined and handle it
appropriately for the benchmark (throw an error, fail the test, or skip the
benchmark with a clear message). This change should reference the
col.currentStateAsChanges call and the result variable so the code defensively
handles the optimizedOnly failure path instead of masking it.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 3f3b4d00-4b84-4442-bc61-b36191bfe2d1

📥 Commits

Reviewing files that changed from the base of the PR and between be656be and 292bacc.

📒 Files selected for processing (7)
  • .changeset/fix-or-partial-union.md
  • .changeset/partial-and-residual-predicate.md
  • packages/db/src/collection/change-events.ts
  • packages/db/src/utils/index-optimization.ts
  • packages/db/tests/collection-indexes.test.ts
  • packages/db/tests/index-optimization.bench.ts
  • packages/db/tests/query/or-partial-optimization.test.ts

Comment thread packages/db/src/utils/index-optimization.ts
Comment thread packages/db/src/utils/index-optimization.ts Outdated
@v-anton v-anton force-pushed the feat/partial-and-residual-predicate branch from 292bacc to 3b47dbd Compare May 28, 2026 09:23
@v-anton
Copy link
Copy Markdown
Author

v-anton commented May 28, 2026

Addressed CodeRabbit review in 3b47dbd:

  • Critical — compound-range bypass: optimizeCompoundRangeQuery now exposes the consumed args; optimizeAndExpression treats the fused range as one optimized branch and runs the standard partition over the remainder, so non-range siblings join the residual.
  • Major — canOptimizeOrExpression inconsistency: introduced canFullyOptimizeExpression (strict variant requiring AND children to have all branches optimizable). Both canOptimizeOrExpression and optimizeOrExpression use it; the runtime path short-circuits via this predicate, so no wasted index lookups before rejecting.
  • Nit — strict-OR tests assert full-scan path: both strict-OR tests now wrap with withIndexTracking and assert shouldUseIndex: false, indexCallCount: 0, fullScanCallCount: 1.
  • Nit — bench non-null assertion: removed !, explicit undefined check with a clear error.

Also restructured the PR description to match the repo template.

Suite: 2379 passed (5 skipped). Bench reruns confirm ~125× partial-index speedup over full scan.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
packages/db/tests/collection-indexes.test.ts (1)

1078-1093: ⚡ Quick win

Assert the optimization path, not only the final rows.

This test currently passes even if execution falls back to full scan. Add index-tracking assertions so it specifically guards the OR-hoist + residual optimization behavior.

Proposed test hardening
 it(`should hoist nested-OR result into AND with unindexed residual`, () => {
-  // or(status='active', status='pending') is fully indexed → 4 candidates.
-  // name='Alice' is the AND residual, narrows to Alice only.
-  const result = collection.currentStateAsChanges({
-    where: and(
-      or(
-        eq(new PropRef([`status`]), `active`),
-        eq(new PropRef([`status`]), `pending`),
-      ),
-      eq(new PropRef([`name`]), `Alice`),
-    ),
-  })!
-
-  expect(result).toHaveLength(1)
-  expect(result[0]?.value.name).toBe(`Alice`)
+  // or(status='active', status='pending') is fully indexed → 4 candidates.
+  // name='Alice' is the AND residual, narrows to Alice only.
+  withIndexTracking(collection, (tracker) => {
+    const result = collection.currentStateAsChanges({
+      where: and(
+        or(
+          eq(new PropRef([`status`]), `active`),
+          eq(new PropRef([`status`]), `pending`),
+        ),
+        eq(new PropRef([`name`]), `Alice`),
+      ),
+    })!
+
+    expect(result).toHaveLength(1)
+    expect(result[0]?.value.name).toBe(`Alice`)
+
+    expectIndexUsage(tracker.stats, {
+      shouldUseIndex: true,
+      shouldUseFullScan: false,
+      indexCallCount: 2,
+      fullScanCallCount: 0,
+    })
+  })
 })
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/db/tests/collection-indexes.test.ts` around lines 1078 - 1093, The
test verifies final rows but not that the OR-hoist + residual index optimization
was used; update the test (the case named "should hoist nested-OR result into
AND with unindexed residual") to assert the query plan/plan metadata in addition
to results: call the same predicate through whatever explain API exists (e.g.,
collection.explainQuery(...) or inspect result.queryPlan/result._meta) when
invoking collection.currentStateAsChanges with and/or/eq/PropRef and assert the
plan shows the OR hoist (i.e., the OR on PropRef([`status`]) produced 4 indexed
candidates) and that name (`PropRef(['name'])`) is treated as the unindexed
residual, failing the test if the plan falls back to a full scan. Ensure these
checks reference collection.currentStateAsChanges and the same predicate
construction so the test protects the optimization path.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/db/tests/collection-indexes.test.ts`:
- Around line 1175-1179: Update the incorrect test comment that states the age
range matches 3 rows—change it to reflect 4 rows (Alice, Bob, Charlie, Diana) in
the block around collection.currentStateAsChanges; this is the comment
referencing optimizeCompoundRangeQuery and optimizeAndExpression in the test, so
simply edit that comment to say "age range matches 4 rows" (or list the four
names) to match the actual test data.

---

Nitpick comments:
In `@packages/db/tests/collection-indexes.test.ts`:
- Around line 1078-1093: The test verifies final rows but not that the OR-hoist
+ residual index optimization was used; update the test (the case named "should
hoist nested-OR result into AND with unindexed residual") to assert the query
plan/plan metadata in addition to results: call the same predicate through
whatever explain API exists (e.g., collection.explainQuery(...) or inspect
result.queryPlan/result._meta) when invoking collection.currentStateAsChanges
with and/or/eq/PropRef and assert the plan shows the OR hoist (i.e., the OR on
PropRef([`status`]) produced 4 indexed candidates) and that name
(`PropRef(['name'])`) is treated as the unindexed residual, failing the test if
the plan falls back to a full scan. Ensure these checks reference
collection.currentStateAsChanges and the same predicate construction so the test
protects the optimization path.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c7e6bf10-e586-4449-b2ca-5a93385b724c

📥 Commits

Reviewing files that changed from the base of the PR and between 292bacc and 3b47dbd.

📒 Files selected for processing (5)
  • .changeset/partial-and-residual-predicate.md
  • packages/db/src/collection/change-events.ts
  • packages/db/src/utils/index-optimization.ts
  • packages/db/tests/collection-indexes.test.ts
  • packages/db/tests/index-optimization.bench.ts
✅ Files skipped from review due to trivial changes (1)
  • .changeset/partial-and-residual-predicate.md
🚧 Files skipped from review as they are similar to previous changes (2)
  • packages/db/src/collection/change-events.ts
  • packages/db/tests/index-optimization.bench.ts

Comment thread packages/db/tests/collection-indexes.test.ts Outdated
Fix soundness bug where `optimizeAndExpression` returned the intersection of
index-optimizable branches and the caller treated those keys as exact matches,
silently dropping the constraint from unindexed branches.

Add an optional `residualPredicate` field to `OptimizationResult`. When set,
`currentStateAsChanges` re-evaluates the predicate against each candidate and
drops rows that fail. AND now collects non-optimizable branches (and any
residuals returned by partially-optimized children) into a single residual,
preserving the index-narrowed candidate set while restoring correctness.

OR rejects partial optimization (including children with residuals) — an
unindexed OR branch can match any row, so the partial result would be unsound.

Benchmark on a 10k-row collection (and(eq(age,25), eq(name,X))):
  - full scan (no indexes):                145 ops/sec
  - partial index (age only, residual):  17,360 ops/sec  (~120× faster)
  - full index (age + name):            277,956 ops/sec  (~16× over partial)
@v-anton v-anton force-pushed the feat/partial-and-residual-predicate branch from 3b47dbd to ca2128b Compare May 28, 2026 10:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant