CodeLogic.MySQL2
4.5.3-preview.58
See the version list below for details.
dotnet add package CodeLogic.MySQL2 --version 4.5.3-preview.58
NuGet\Install-Package CodeLogic.MySQL2 -Version 4.5.3-preview.58
<PackageReference Include="CodeLogic.MySQL2" Version="4.5.3-preview.58" />
<PackageVersion Include="CodeLogic.MySQL2" Version="4.5.3-preview.58" />
<PackageReference Include="CodeLogic.MySQL2" />
paket add CodeLogic.MySQL2 --version 4.5.3-preview.58
#r "nuget: CodeLogic.MySQL2, 4.5.3-preview.58"
#:package CodeLogic.MySQL2@4.5.3-preview.58
#addin nuget:?package=CodeLogic.MySQL2&version=4.5.3-preview.58&prerelease
#tool nuget:?package=CodeLogic.MySQL2&version=4.5.3-preview.58&prerelease
CodeLogic.MySQL2
v4.0.0 — major rewrite. Typed LINQ translated to SQL, compiled row materializers, working result cache (time-quantized keys + table-version invalidation), SQL-side aggregation (
GroupBy+ aggregatingSelect), projection pushdown, covering indexes, attribute-driven retention. See the Performance docs for benchmark numbers.
MySQL / Percona / MariaDB library for CodeLogic. Typed LINQ-shaped queries translated to SQL, compiled row materializers, working result cache, server-side aggregation, and covering indexes driven by attributes. Built on MySqlConnector.
Install
dotnet add package CodeLogic.MySQL2
Quick start
await Libraries.LoadAsync<MySQL2Library>();
var mysql = Libraries.Get<MySQL2Library>();
// CRUD via the repository
var repo = mysql.GetRepository<UserRecord>();
await repo.InsertAsync(new UserRecord { Name = "Alice", Email = "alice@example.com" });
// Typed LINQ — translated to SQL
var activeAdmins = await mysql.Query<UserRecord>()
.Where(u => u.IsActive && u.Role == "admin")
.OrderBy(u => u.Name)
.WithCache(TimeSpan.FromMinutes(5))
.ToPagedListAsync(page: 1, pageSize: 20);
What's in the box
Typed query builder
Full expression translation to SQL — no magic strings in consumer code.
| Capability | Shape |
|---|---|
| Filter | .Where(x => x.Status == "active" && x.Age >= 18) |
| Sort | .OrderBy, .OrderByDescending, .ThenBy, .ThenByDescending |
| Paging | .Take, .Skip, .ToPagedListAsync |
| String ops | Contains / StartsWith / EndsWith → LIKE (escaped) |
| IN | list.Contains(x.Col) → x.Col IN (...) |
| Null | x.Col == null → IS NULL; string.IsNullOrEmpty(x.Col) too |
| Nullable | x.NullableCol.Value passthrough |
Projection pushdown
.Select<TResult>(x => new Foo(x.A, x.B)) emits a real column list — only the
columns referenced are transferred from the DB, not SELECT *.
var lean = await mysql.Query<PostRecord>()
.Where(p => p.PublishedUtc >= since)
.Select(p => new { p.Id, p.Title, p.Slug }) // ships 3 columns, not 15
.ToListAsync();
SQL aggregation (GroupBy → Select)
GroupBy + aggregating Select translate to real GROUP BY + SUM / AVG /
COUNT / MIN / MAX / ANY on the server. No rows materialize client-side.
var heatmap = await mysql.Query<SnapshotRecord>()
.Where(s => s.SnapshotUtc >= since)
.GroupBy(s => new { Dow = SqlFn.DayOfWeek(s.SnapshotUtc),
Hour = SqlFn.Hour(s.SnapshotUtc) })
.Select(g => new HeatmapCell(
g.Key.Dow,
g.Key.Hour,
g.Average(x => (double)x.PlayerCount)))
.ToListAsync();
Inside Select(g => ...) you can call:
g.Key, g.Key.Member, g.Sum(x => ...), g.Average(x => ...), g.Min, g.Max,
g.Count(), g.Count(x => pred), g.Any(), g.Any(pred).
Ternary inside aggregates becomes CASE WHEN:
g.Sum(x => x.IsOnline ? 1 : 0) → SUM(CASE WHEN is_online THEN 1 ELSE 0 END).
SqlFn helpers
SQL function markers recognised by the translator (same pattern as EF.Functions):
| Group | Methods |
|---|---|
| Date/time | Year, Month, Day, Hour, Minute, DayOfWeek (0=Sun..6=Sat), Date, BucketUtc(d, seconds) |
| Conditional | Coalesce(...), IfNull(v, fallback) |
| String | Lower, Upper, Concat(...), Like(s, pattern) |
| Math | Round(v, digits), Floor, Ceiling |
Result cache (now actually working)
.WithCache(TimeSpan.FromMinutes(5))
Two things that weren't right before and now are:
- DateTime closures near "now" are time-quantized — a
.Where(x => x.At >= UtcNow.AddDays(-30))predicate no longer produces a unique cache key per call. The window is configurable (CacheConfiguration.TimeQuantizeSeconds, default 60s). - Table-version invalidation — mutations bump a per-table counter baked into the key. No eviction loop; old keys simply become un-hittable.
Cache hits and misses publish CacheHitEvent / CacheMissEvent on the
CodeLogic event bus.
Smart cache pools — kept warm in the background (new in 4.2)
For pages where a small set of queries should stay hot regardless of read traffic, register a named pool with a refresh interval and opt queries into it. The pool's background timer re-runs every registered query and overwrites the cache entry — readers never block on the DB after the first populate.
// Declare the pool once at startup (typically in OnInitializeAsync).
mysql.RegisterCachePool("dashboard", refreshEvery: TimeSpan.FromSeconds(30));
// Opt queries into the pool. First call: cold DB hit, result cached, query
// registered with the pool. Subsequent calls: cache hit. Every 30s the
// pool's timer re-runs the query and refreshes the entry.
var top10 = await mysql.Query<PlayerRecord>()
.Where(p => p.IsActive)
.OrderByDescending(p => p.Score)
.Take(10)
.SmartCache("dashboard")
.ToListAsync();
// Out-of-schedule refresh — useful right after a deploy to prime the cache.
await mysql.RefreshCachePoolAsync("dashboard");
// Warm the cache on startup — readers never see a cold pool. The warm-up
// callback just calls the queries that should be hot; they auto-register
// with the pool via .SmartCache("dashboard") as usual. Runs as a fire-and-
// forget task so startup doesn't block.
mysql.RegisterCachePool("dashboard", TimeSpan.FromSeconds(30),
warmUp: async () =>
{
await statsService.GetDashboardTop10Async();
await statsService.GetRecentMatchesAsync();
});
// Diagnostic snapshot
foreach (var s in mysql.GetCachePoolStats())
Console.WriteLine($"{s.Name}: {s.EntryCount} entries, {s.TicksFired} ticks fired");
How it behaves:
- TTL is derived from the pool —
refreshEvery * 2. Cache entries outlive a missed refresh by one cycle before falling back to cache-aside. - Bounded cardinality — an entry that has not been read for
MaxIdleFires(default 3) consecutive ticks is dropped from the refresh list. Parameterized queries (per-user keys, etc.) don't spawn unbounded background work — they auto-retire when nobody's looking. - Mutually exclusive with
.WithCache(TimeSpan)— if both are set, the pool wins. - Falls back gracefully — an unknown pool name on
.SmartCache(name)logs a warning and the query runs uncached. No exception. - Skipped inside transactions — same rule as
.WithCache. - Single-node only — coordination across multiple app instances is on
the roadmap. With a Redis-backed
ICacheStoretoday, every node will run its own refresh timer; that's safe but wasteful at high node counts.
Bulk writes / predicate mutations
// Real batched INSERT ... VALUES (...), (...), ... (configurable chunk size)
await repo.InsertManyAsync(rows);
// Typed bulk update — one UPDATE statement, values or column expressions
await mysql.Query<TicketRecord>()
.Where(t => t.Status == "open" && t.CreatedUtc < cutoff)
.UpdateAsync(t => new TicketRecord { Status = "stale", Counter = t.Counter + 1 });
// Bulk delete by predicate
await mysql.Query<SnapshotRecord>()
.Where(s => s.SnapshotUtc < cutoff)
.DeleteAsync();
Schema sync driven by attributes
Record classes are the source of truth. SyncTableAsync<T>() creates / alters
MySQL tables to match.
[Table(Name = "servers_snapshot")]
[RetainDays(90, nameof(SnapshotUtc))] // daily background purge
[CompositeIndex("ix_server_snapshot", "server_id", "snapshot_utc")]
public sealed class SnapshotRecord
{
[Column(DataType = DataType.BigInt, Primary = true, AutoIncrement = true)]
public long Id { get; set; }
[Column(Name = "server_id", DataType = DataType.BigInt, NotNull = true)]
public long ServerId { get; set; }
[Column(Name = "snapshot_utc", DataType = DataType.DateTime, NotNull = true)]
[Index(Name = "ix_snapshot_utc_covering",
Include = new[] { nameof(ServerId), nameof(PlayerCount) })] // covering index
public DateTime SnapshotUtc { get; set; }
[Column(Name = "player_count", DataType = DataType.Int, NotNull = true)]
public int PlayerCount { get; set; }
}
Attributes supported:
| Attribute | Purpose |
|---|---|
[Table] |
table name, engine, charset, collation, comment |
[Column] |
type, size, nullability, default, PK/AI/Unique/Index |
[Index] |
new — named, unique, covering (Include) |
[CompositeIndex] |
multi-column named index on the class |
[ForeignKey] |
FK with ON DELETE/UPDATE actions |
[RetainDays] |
new — background purge job for time-series tables |
[Ignore] |
skip property for schema / read / write |
Observability
The library publishes events to CodeLogic's event bus:
QueryExecutedEvent— every query: SQL, elapsed ms, row count, cache hit flagSlowQueryEvent— queries over the threshold (logged too)CacheHitEvent/CacheMissEvent— per-callN1QueryDetectedEvent— when the detector tripsTableSyncedEvent,DatabaseConnected/Disconnected— lifecycle
Background retention worker
Entities with [RetainDays(N, nameof(TimestampCol))] are picked up automatically
when registered via SyncTableAsync<T>(). A daily pass runs batched
DELETE WHERE {col} < NOW() - INTERVAL N DAY LIMIT batchSize until drained.
Configuration
Two config sections are auto-generated on first run under
data/codelogic/Libraries/CL.MySQL2/.
config.mysql.json — per-database settings. Highlights:
| Field | Default | Purpose |
|---|---|---|
Host, Port, Database, Username, Password |
— | connection |
EnablePooling, MinPoolSize, MaxPoolSize, ConnectionLifetime |
true/1/100/300s | pool |
SchemaSyncLevel |
Safe |
None / Safe / Additive / Full |
SlowQueryThresholdMs |
1000 | slow query logging |
QueryTimeoutMs |
30000 | default per-query timeout |
MaxBatchInsertSize |
500 | InsertManyAsync chunk size |
MaxInClauseValues |
1000 | IN-clause safety cap |
PreparedStatementCacheSize |
256 | per-connection |
N1DetectorThreshold |
0 (off) | warn on repeated query in request scope |
CaptureExplainOnSlowQuery |
true | attach EXPLAIN to SlowQueryEvent |
DefaultStringSize |
255 | VARCHAR size when no [Column(Size)] |
CacheEnabledOverride |
null (inherit) | per-DB cache on/off |
config.mysql.cache.json — global cache settings. Highlights:
| Field | Default | Purpose |
|---|---|---|
Enabled |
true | master switch |
MaxEntries |
10000 | soft cap |
DefaultTtlSeconds |
60 | when .WithCache() has no TTL |
TimeQuantizeSeconds |
60 | round DateTime params in cache keys |
PublishEvents |
true | emit hit/miss events |
Transactions
await using var tx = await mysql.BeginTransactionAsync();
var repo = mysql.GetRepository<AccountRecord>(tx);
// ... work ...
await tx.CommitAsync(); // auto-rolls back on dispose if not committed
Requirements
- CodeLogic 3.x or 4.x
- .NET 10
- MySQL 5.7+ / MariaDB 10.2+ / Percona 8.x
License
MIT — see LICENSE
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- CodeLogic (>= 3.2.0 && < 5.0.0)
- MySqlConnector (>= 2.5.0)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 4.5.4-preview.59 | 66 | 5/24/2026 |
| 4.5.3-preview.58 | 42 | 5/24/2026 |
| 4.5.2 | 97 | 5/24/2026 |
| 4.5.2-preview.57 | 56 | 5/24/2026 |
| 4.5.1 | 103 | 5/24/2026 |
| 4.5.1-preview.60 | 51 | 5/24/2026 |
| 4.5.1-preview.56 | 57 | 5/24/2026 |
| 4.4.2-preview.53 | 44 | 5/24/2026 |
| 4.4.1 | 94 | 5/24/2026 |
| 4.4.1-preview.55 | 48 | 5/24/2026 |
| 4.4.1-preview.52 | 44 | 5/24/2026 |
| 4.3.0-preview.51 | 50 | 5/24/2026 |
| 4.2.3 | 122 | 5/15/2026 |
| 4.2.2 | 118 | 5/15/2026 |
| 4.2.1 | 91 | 5/15/2026 |
| 4.2.0 | 115 | 5/15/2026 |
| 4.1.3 | 99 | 5/15/2026 |
| 4.1.2 | 99 | 5/15/2026 |
| 4.1.1 | 212 | 5/10/2026 |
| 4.1.0 | 112 | 5/9/2026 |
# CL.MySQL2 — Changelog
All notable changes to **CodeLogic.MySQL2** are documented here. Versions follow
[Semantic Versioning](https://semver.org/). The version listed here matches the
NuGet package version of `CodeLogic.MySQL2`.
## [4.5.0] — 2026-05-24
### Added
- **`StorageType` enum on `ColumnAttribute`.** Per-column physical storage
override that takes precedence over `DataType` for DDL generation.
Available values: `Binary`, `VarBinary`, `TinyBlob`, `Blob`, `MediumBlob`,
`LongBlob`. When set, the column is stored as the chosen binary type and
values are automatically converted to/from binary on read and write.
- **Guid-as-BINARY(16) support.** Set `StorageType = StorageType.Binary` on a
`Guid` property and CL.MySQL2 stores it as `BINARY(16)` using RFC 4122
big-endian byte layout for correct lexicographic sort order. Conversion is
automatic in all paths: insert, update, read, WHERE clauses, and IN queries.
```csharp
[Column(StorageType = StorageType.Binary, Primary = true, NotNull = true)]
public Guid Id { get; set; }
```
- **Automatic binary conversion for all CLR types.** Any property can be
stored as binary by setting `StorageType`. Supported types and their binary
sizes (auto-detected when `Size` is not explicit):
| CLR type | Binary size | Byte order |
|---|---|---|
| `Guid` | 16 | RFC 4122 big-endian |
| `long` / `ulong` | 8 | big-endian |
| `int` / `uint` | 4 | big-endian |
| `short` / `ushort` | 2 | big-endian |
| `double` | 8 | big-endian |
| `float` | 4 | big-endian |
| `decimal` | 16 | big-endian |
| `DateTime` / `DateTimeOffset` | 8 | ticks, big-endian |
| `bool` / `byte` / `sbyte` | 1 | — |
| `string` | explicit | UTF-8 |
| `byte[]` | passthrough | as-is |
- **LINQ WHERE support for binary-stored columns.** Expressions like
`repo.Where(x => x.Id == someGuid)` and `list.Contains(x.Id)` correctly
convert parameter values to binary when the column uses `StorageType`.
- **`SequentialGuid.NewId()` helper.** Generates time-ordered UUIDv7 values
optimized for `BINARY(16)` primary keys. Sequential inserts append to the
B-tree instead of causing random page splits — dramatically reducing index
fragmentation compared to random UUIDv4.
```csharp
[Column(StorageType = StorageType.Binary, Primary = true, NotNull = true)]
public Guid Id { get; set; } = SequentialGuid.NewId();
```
- **Unified versioning.** All CodeLogic.Libs now share a single version line
controlled by `version.txt`. AssemblyVersion is derived automatically.
### Notes
- **No breaking changes.** `StorageType` defaults to `StorageType.Default`
(the zero-value), so all existing entities and schemas are unaffected.
Guid inference still returns `Char(36)` unless you explicitly opt in.
## [4.2.3] — 2026-05-15
### Fixed
- **Cache orphan accumulation on mutations.** `QueryCache.Invalidate(tableName)`
previously only bumped the per-table version counter — old cache entries
(now unreachable via the read path because the cache key changed) lingered
in the underlying store until TTL or LRU swept them. On a busy app this
produced unbounded memory growth. Invalidate now also calls
`ICacheStore.EvictByTableAsync` to sweep matching entries in the same step.
- **SmartCachePool orphan tracking.** Each pool entry now remembers the
cache key it last wrote. If the next tick computes a different key
(because a mutation bumped the table version between ticks), the
previous key is evicted explicitly. Works on any `ICacheStore`
implementation including ones that can't enumerate (Redis without
SCAN, memcached).
### Added
- `ICacheStore.EvictByTableAsync(tableName)` — bulk eviction by table.
Default in-process implementation is an O(n) scan over current entries.
Distributed adapters can override (e.g. Redis tag-set or key prefix).
- `ICacheStore.CountByTable()` — entries grouped by tableName for diagnostics.
- `QueryCache.GetStats()` → `QueryCacheStats(TotalEntries, EntriesByTable,
TableVersions)`. Surfaced on the library API via `MySQL2Library.GetCacheStats()`
so admin tools can render "what's in the cache right now" without
dumping values.
## [4.2.2] — 2026-05-15
### Fixed
- **SmartCache pool no longer corrupts `ToListAsync` results.** The pool's
refresh factory stored the unwrapped `List<T>` instead of the
`Result<List<T>>` that the cache-aside read path expects. After the first
background refresh tick, every subsequent read failed the `(Result<List<T>>)`
cast inside `GetOrSetAsync`, the outer try/catch turned it into a Failure
Result, and callers saw an empty list (manifested as "No servers configured"
/ empty leaderboards roughly one refresh interval after warm-up). `FirstOrDefaultAsync`
and `CountAsync` already cached the full Result and were unaffected; only
`ToListAsync` was wrong.
## [4.2.1] — 2026-05-15
### Fixed
- **Failure Results no longer poison the cache.** Previously, a query that
failed (e.g. transient connection error during a cold warm-up) had its
`Result<T>.Failure` value cached just like a successful one — subsequent
reads served the failure until the entry's TTL expired or a pool refresh
overwrote it. Now `QueryCache.GetOrSetAsync` skips writing failure Results,
evicts any pre-existing failure entry on read, and `SetDirectAsync` (the
smart-cache pool's refresh path) refuses to write failures too. Empty
server lists / leaderboards on first request after a deploy are gone.
## [4.2.0] — 2026-05-15
### Added
- **Smart-cache pool warm-up on registration.** `RegisterCachePool` now
accepts an optional `warmUp: Func<Task>` callback that fires as a
fire-and-forget task right after the pool starts. The callback just
calls the queries that should be warm — they auto-register with the
pool via their normal `.SmartCache(name)` decoration — so the cache
is hot before the first user request hits it. Exceptions are caught
and logged; the pool stays lazy if warm-up fails.
- `SmartCachePool.WarmUp(Func<Task>)` — public method exposing the same
behaviour for callers that want to warm a pool independently of
registration.
## [4.1.2] — 2026-05-15
### Added
- **Smart cache pools** — named groups of cached queries kept warm by a
background timer (`mysql.RegisterCachePool("dashboard", refreshEvery: 30s)`,
opt in per-query with `.SmartCache("dashboard")`). Reads after the first
populate the cache never block on the DB — the pool's timer re-runs every
registered query in the background and overwrites the entry.
- `SmartCachePool.RefreshNowAsync()` — out-of-schedule refresh, useful right
after a deploy to prime the cache before the first user hits the page.
- `MySQL2Library.GetCachePoolStats()` — diagnostic snapshot per pool
(entry count, ticks fired, ticks failed, last tick UTC).
- `QueryCache.SetDirectAsync(...)` — internal cache write API used by pools.
### Notes
- Smart cache is mutually exclusive with `.WithCache(TimeSpan)` — if both are
set, the pool wins and the TTL comes from `refreshEvery * 2`.
- Unknown pool name on `.SmartCache(name)` logs a warning and falls back to
non-cached execution (no exception).
- Per-pool eviction policy: an entry that has not been read for
`MaxIdleFires` (default 3) consecutive ticks is dropped from the refresh
list. Bounds cardinality on parameterized queries.
- Smart cache is disabled inside a transaction scope (same as `.WithCache`).
- Single-node only in v4.2. Multi-node coordination is on the roadmap.
## [4.1.1] — 2026-04-17
### Fixed
- Qualify LHS columns in upsert SET clauses so `UpsertAsync` no longer
generates ambiguous column references when the table has columns whose
names clash with parameter placeholders.
## [4.1.0] — 2026-04-17
### Added
- **Typed upsert** — `UpsertAsync` + `UpsertWithIncrementsAsync` on the
repository. Compiles to `INSERT ... ON DUPLICATE KEY UPDATE ...` with
full LINQ-shaped value/increment expressions on the update side.
### Changed
- `LibraryManifest.Version` now reads from the assembly's `AssemblyVersion`
attribute at runtime instead of being a hard-coded string. Keeps the
manifest honest across rebuilds.
## [4.0.4] — 2026-04-16
### Changed
- README + manifest refresh across every CodeLogic library for the v4 baseline.
- No functional changes vs 4.0.3.
## [4.0.1] — 2026-04-09
### Fixed
- Drop the `Expression.Compile().DynamicInvoke()` fast-path inside the SQL
expression visitor — it broke on closures over generic types. The visitor
now always walks the tree.
## [4.0.0] — 2026-04-09
Major rewrite. Breaking.
### Added
- **Projection pushdown** — `.Select<TResult>(x => new { ... })` emits a
real `SELECT col1, col2, ...` column list instead of `SELECT *`. Combined
with compiled materializers this often cuts row-transfer bandwidth by 80%+.
- **SQL-side aggregation** — `.GroupBy(...).Select(g => new { g.Key,
g.Sum(...), g.Average(...), ... })` translates to real `GROUP BY` +
aggregate functions. No client-side row materialization.
- **`SqlFn` helpers** — server-side function markers (`SqlFn.DayOfWeek`,
`SqlFn.Hour`, `SqlFn.BucketUtc`, `SqlFn.Coalesce`, `SqlFn.Round`, etc.)
recognized by the translator — mirrors EF's `EF.Functions` pattern.
- **`[Index]` attribute** — declare named, unique, and covering indexes
(with `Include = new[] { ... }`) at the column level.
- **`[RetainDays]` attribute** — opt entities into a daily background purge
worker that runs batched `DELETE` until drained.
- **Working result cache** — `.WithCache(TimeSpan)` with two correctness
fixes from prior versions:
- DateTime closures near `UtcNow` are time-quantized to a configurable
window (default 60s) so `.Where(x => x.At >= UtcNow.AddDays(-30))`
stops producing a unique cache key per call.
- Mutations bump a per-table version that participates in the cache
key — invalidation is free (old keys become un-hittable, no eviction
loop).
- **`EntityMetadata<T>` + compiled `Materializer<T>`** — reflection runs
once per entity at first use; subsequent reads use a compiled
reader-to-entity function.
- **Observability events** — `QueryExecutedEvent`, `SlowQueryEvent`,
`CacheHitEvent`, `CacheMissEvent`, `N1QueryDetectedEvent`,
`TableSyncedEvent` publish to the CodeLogic event bus.
- **`MaxBatchInsertSize`, `MaxInClauseValues`, `PreparedStatementCacheSize`,
`N1DetectorThreshold`, `CaptureExplainOnSlowQuery`, `DefaultStringSize`,
`CacheEnabledOverride`** — per-database config knobs.
- **`CacheConfiguration`** — global cache settings (`Enabled`,
`MaxEntries`, `DefaultTtlSeconds`, `TimeQuantizeSeconds`,
`PublishEvents`).
### Changed
- Republished as v4.0.0 to reset the version line with the new package shape.
- All public APIs refreshed under the v4 baseline.
## Earlier releases
Pre-4.0 history is retained in the
[git log](https://github.com/Media2A/CodeLogic.Libs/commits/main/CL.MySQL2)
but is not documented in detail here — the library shape changed
significantly in the v4 rewrite.