UpsertObjectAsyncreports whether the write inserted or updated. ReturnsUpsertResult.Insertedwhen no row existed for the key in that partition andUpsertResult.Updatedwhen one did and its data was replaced; stored contents are identical toWriteObjectAsync. Implemented asINSERT OR IGNOREplus, only when nothing was inserted, an in-placeUPDATE, both under the connection gate and the transaction — so callers that keep an incremental view of the store (a queue count, an added/removed signal) no longer need a read-then-write pair guarded by a lock of their own. Registered-id and explicit key-selector overloads, same strict-mode divergence guard. There is deliberately no failure value: any failure (insert ignored for another constraint, update not affecting exactly one row, a serializer or SQLite error) throwsTychoExceptionand leaves the row as it was — with a transaction it is rolled back, without one the single failing statement is atomic on its own. (#32)
This release closes a critical SQL-injection vector and a data-integrity bug, and adds proven write/startup performance improvements. It is a major version because some query behavior changes (see Breaking changes).
- Critical: SQL injection via filter values fixed. Filter comparison values were
concatenated directly into the SQL text, allowing full data disclosure and
destruction (a stacked-statement value on a read could
DELETErows). All filter values are now bound as parameters. Genuine numeric/boolean CLR values are emitted as validated literals; everything else is parameterized. - Path & identifier validation. The raw-string overloads
FilterBuilder.Filter(FilterType, string propertyPath, …),SortBuilder.OrderBy(SortDirection, string), andCreateIndex(…)(property path, object type name, and index name) now validate their inputs against a strict grammar and throwArgumentExceptionon anything that could be an injection vector. - LIKE escaping.
Contains/StartsWith/EndsWithnow escape%,_, and\with an explicitESCAPEclause, so those characters match literally and cannot be used to force full-table scans. - The
CA2100analyzer suppressions were narrowed and justified (values are parameterized; only validated identifiers/paths remain concatenated).
-
Critical: an ungrouped
Or()escaped the partition and type predicates. The caller's filter was appended to the generatedWHEREclause without being bound as a unit:WHERE FullTypeName = ? AND Partition = ? AND <term1> OR <term2>
ANDbinds tighter thanOR, so SQL read that as(FullTypeName = ? AND Partition = ? AND term1) OR (term2)— every term after the firstOr()was matched against the whole table. A two-termOr()returned rows from other partitions, and rows of other stored types, which the reader then deserialized asTwith no error. The same clause is used byDeleteObjectsAsync, so an ungroupedOr()could delete rows in other partitions and of other types, and byCountObjectsAsync, which over-counted. The caller's filter is now emitted inside its own parentheses. Losing thePartitionpredicate also cost the partition-prefixed indexes, so this was a large performance regression as well as a correctness one; grouped OR-chains now use the index. Filters already wrapped inStartGroup()/EndGroup()were unaffected and still are. -
LINQ predicates lost their own precedence.
TychoQueryabletranslated&&and||by emitting their operands flat, soWhere(x => (x.A || x.B) && x.C)becameA OR B AND C— read by SQL asA OR (B AND C)— and returned rows matching onlyAdespite their failingC. Each composite boolean node is now emitted in its own group. (.Where(a).Where(b)chains were affected the same way when either predicate contained an||.) -
Data integrity: filter values are now compared in the form the serializer wrote. A filter value was rendered with
ToString(), which is not how the serializer stores it for every type. The clearest case is an enum: both serializers write it as a number by default, soFilter(Equals, x => x.StoreAllocation, StoreAllocationType.Produce)compared the stored0against the text"Produce"and matched nothing, while the(int)-cast workaround matched. With a string-enum converter the name happened to line up — unless a naming policy renamed it, which broke it again. A full sweep of the scalar type surface found five types affected on both serializers:enum, enums renamed by a converter or naming policy, nullable enums,DateOnly, andTimeOnly. The two date types were additionally culture-dependent —DateOnly.ToString()yields8/28/2026underen-USagainst a stored2026-08-28— so the same code matched or failed depending on the machine's locale. Values are now resolved through the newIJsonValueResolver.string,bool, the numeric primitives,DateTimeandDateTimeOffsetare unchanged;Guid,TimeSpan,Uriandcharalready agreed with their JSON form and still do. -
Data integrity: property expressions now honour the serializer's member names. Expressions such as
x => x.Descriptionbuilt the JSON path from the CLR property name ($.Description), ignoringPropertyNamingPolicy,[JsonPropertyName],[JsonProperty], and Newtonsoft contract resolvers. Any serializer configuration that renames members therefore produced a path matching nothing in the stored document. Because an unmatched JSON path is not an error in SQLite, this failed silently:ReadObjectsAsyncreturned zero rows,SortBuilder.OrderBydid not sort, andCreateIndex/CreateIndexAsyncbuilt indexes that never matched a row — with no exception and nothing logged. Serializers now report their JSON member names via the newIJsonPropertyNameResolver, and expression paths are resolved against them. -
The projection overloads now handle every JSON value kind.
ReadObjectsAsync<TIn, TOut>/ReadObjectsWithKeysAsync<TIn, TOut>selected the member withJSON_EXTRACT, which converts the match to an SQL value: a JSON string was unwrapped to bare text (target, not"target") andtrue/falsecollapsed to the integers1/0. Handing those to a JSON deserializer failed — projecting astringthrew "invalid JSON literal", and projecting aboolthrew "cannot get the value of a token type 'Number' as a boolean" underSystem.Text.Json(Newtonsoft silently coerced1totrue). Projection now uses SQLite's->operator, which returns the JSON representation, so strings, numbers, booleans, objects and arrays all round-trip. -
Projecting a member that is absent no longer throws. A member that was never written (or stored as JSON null) produced SQL NULL, and the reader called
GetStreamon it — failing withInvalidOperationException: The data is NULL at ordinal 2. An absent member is now reported asdefault(TOut):nullfor reference and nullable types, zero/falsefor value types. -
Data integrity:
NewtonsoftJsonSerializerno longer emits a UTF-8 BOM. The BOM made stored JSON malformed for SQLite'sjson()on stricter/older builds — notably the SQLCipher bundle — breaking every Newtonsoft-serialized write onTychoDB.Encrypted. Serialization now uses BOM-less UTF-8.
Indexing was measured end-to-end and rebuilt. Full evidence, before/after benchmarks and query plans: docs/indexing-analysis.md.
- Critical: indexes on value-type properties indexed the entire document.
CreateIndex<T>(x => x.Age, …)— and everyint,long,double,bool,DateTime,Guid, enum, or nullable property — generatedJSON_EXTRACT(Data, '$'), storing a complete second copy of every document in the index. Those indexes could never be used by any query, so they cost storage and write throughput for zero benefit. The boxingConvertnode introduced byExpression<Func<T, object>>is now unwrapped, producing the real property path and the correct numeric form. - Partial expression indexes. Indexes are now
ON JsonValue(Partition, <expr>…) WHERE FullTypeName = '<type>': scoped to one stored type, led by thePartitioncolumn every query constrains. Index size on the benchmark dataset dropped ~82%. - Sorting can now use an index.
SortBuilderemittedData ->> '$.x', which can never match aJSON_EXTRACTexpression index, so every sorted read built a temporary b-tree. It now emits the same expression the index is built on. - Redundant built-in indexes removed. Three of the four
JsonValueindexes and theStreamValueindex duplicated the primary-key autoindexes or a prefix of another index. They are dropped (idempotently, so existing databases shed them on connect), cutting maintained b-trees from five to two. Query plans are unchanged, which is covered by a regression test. - Index metadata, dedup and migration. A
TychoIndextable records each index, so re-declaring an unchanged index is a cheap metadata lookup, changing an index's definition rebuilds it and drops the stale b-tree, and indexes from older versions are migrated automatically on the nextCreateIndexcall. - Cross-namespace index-name collisions fixed. Physical index names carry a stable
hash of the full type name. Previously two same-named types in different namespaces
shared one index name and the second
CREATE INDEX IF NOT EXISTSsilently did nothing. - Planner statistics. A bounded
ANALYZEruns after an index is created, andPRAGMA optimizenow also runs on connect. Previouslysqlite_stat1was never created at all, so the planner always ran on default heuristics. - New API:
DropIndex<T>,DropIndexAsync<T>, andListIndexes()(additive), plusSortBuilder.OrderBy(SortDirection, string propertyPath, bool isPropertyPathNumeric)so the raw-string sort overload can emit the numeric form its index is built on — matching the existing raw-stringFilterBuilder.Filteroverload. - Closed generic types are indexable. Derived type names for closed generics contain
characters that are not valid in a SQL identifier (e.g.
Dictionary_2__String,Int32__); they are now normalized instead of rejected. Caller-supplied identifiers are still validated strictly.
Measured on 25,000 rows: numeric equality 6,320 → 18.5 µs, numeric range 6,372 → 85 µs, sorts ~6,700 → ~53 µs (all previously gained nothing from an index); batch writes −44%; database file with three indexes 20.2 → 8.4 MiB.
-
CountObjectsAsyncno longer counts rows on the client. It issuedSELECT 1 FROM JsonValue WHERE …and incremented a counter once per matching row, costing a reader round trip per row. It now issuesSELECT COUNT(*)and reads the single scalar: 16.0 ms → 6.5 ms counting a 250,000-row partition (2.5x). The same query backs the pre-count a progress-reportingReadObjectsAsyncperforms, so progress-enabled reads pay half of what they did. A filtered count is still bounded by whether the filtered property is indexed — counting a 1-in-200 selective filter on an unindexed path takes ~79 ms on the same store, essentially all of it theJSON_EXTRACTscan. -
PRAGMA optimizeon connect and disconnect.Connect/ConnectAsyncandDisconnect/DisconnectAsync/Disposerun SQLite's recommendedPRAGMA optimize(bounded byanalysis_limit = 400) so the query planner keeps fresh statistics and continues to choose indexes — including expression indexes overJSON_EXTRACT. The connect-time call matters for long-lived mobile apps that never cleanly disconnect. -
Bounded WAL on mobile. The
Mobileprofile setsjournal_size_limit = 8 MBso the WAL file truncates after a checkpoint instead of growing unbounded;Desktopleaves it unlimited. -
Cleanuptruncates the WAL.Cleanup(vacuum: true)now runswal_checkpoint(TRUNCATE)after reclaiming free space, returning the WAL file's space to disk as well. -
Device-aware SQLite tuning. A new
TychoPerformanceProfile(Mobile/Desktop) constructor parameter selects a preset of PRAGMA tuning, with optionalcacheSizeKb/mmapSizeBytesoverrides.Mobile(the default) uses a small page cache (8 MB), a modest 32 MB memory-map, and frequent WAL checkpoints to keep memory and the WAL file small;Desktopuses a 64 MB cache, a 256 MB memory-map, and less frequent checkpoints for read/write throughput. Previously a single fixed set (16 MB cache / 128 MB mmap) was used for all devices. -
Cache=Sharedwas removed from the connection string; it contradictedlocking_mode = EXCLUSIVE(single persistent connection), so a private cache is used. -
Bulk writes batched.
WriteObjectsAsyncnow writes rows in multi-rowINSERT OR REPLACEbatches (100 rows/execution) and no longer runs a redundantSELECT last_insert_rowid()per row. Measured (System.Text.Json, 1000 objects): −16% time, −62% allocations (1.66 MB → 631 KB). Individual writes: −21% time. -
cache_size/mmap_sizePRAGMAs applied. The intended page-cache tuning was defined but never wired up; it is now applied on connect (helps datasets larger than the default cache). -
Lighter connection gate. The per-operation
ConcurrencyLimiterwas replaced with aSemaphoreSlim(1,1), which is lighter and also genuinely serializes synchronous callers (the previousAttemptAcquire()path did not). -
Cheaper connect. The SQLite JSON/version support check is now performed once per process instead of on every
Connect()(−16% connect time). -
Single-object writes avoid an extra
Listallocation (IList<T>fast path).
-
AddTypeRegistration<T>()now detects the id property by convention, as documented. It previously did nothing of the kind: it recorded no selector, soWriteObjectAsync(obj),ReadObjectAsync(obj),ObjectExistsAsync(obj),DeleteObjectAsync(obj)andGetIdFor(obj)all threwTychoException: An id mapping has not been provided, on a type whose property was literally namedId. The property is now found by name —Id, then<TypeName>Id, matched case-insensitively, and it must be public, readable and non-indexed. When no such property exists the type is still registered but without an id mapping, exactly as before, so registering a key-less type and supplying keys at the call site keeps working. -
Strict registration now rejects a key that diverges from the registered id property.
WriteObjectsAsync(objs, keySelector, …)takes a key at the call site and overrides the registration. A row written under a key the registration would not produce is unreachable by every by-object overload, and the delete failure is silent —DeleteObjectAsync(obj)returns false while the row survives. WithrequireTypeRegistration: trueand a type registered by id property, such a write now throwsTychoExceptionnaming both keys. Delegate registrations (AddTypeRegistrationWithCustomKeySelector) have no property to compare against and are unaffected, as is everything outside strict mode. The check wraps the selector rather than pre-scanning, so a lazy sequence is still enumerated exactly once. -
Filters on the id property are answered from the
Keycolumn where that is provably correct.Filter(Equals, x => x.Id, …)andFilter(In, x => x.Id, …)previously went throughJSON_EXTRACTand scanned. UnderrequireTypeRegistrationwith a type registered by id property, they are now emitted against the indexedKeycolumn instead:filter on the id property, 250,000 rows scan rewritten Equals79.3 ms 0.0 ms In, 100 keys101.2 ms 0.2 ms Soundness comes from two things together: the write guard above means no row written through this instance can diverge, and rows already in the database are checked once per type with a divergence probe before the rewrite is used (~92 ms on that store, on the first such query only, then cached for the connection). A single divergent row disables the rewrite for that type and the ordinary predicate is emitted, so the worst case is the behaviour that was there before. Negated forms (
NotEquals,NotIn) are deliberately left alone — they cannot use an index either way — as is a null comparison value, sinceKeyisNOT NULL. -
ReadObjectsByKeysAsync<T>(keys, partition, sort, …). Reads a batch of keys in one round trip. The key set is bound as a single JSON array expanded byJSON_EACH, not as one parameter per key, so there is noSQLITE_MAX_VARIABLE_NUMBERceiling (999 on older SQLite builds), no chunking for callers to think about, and one prepared statement regardless of batch size. Keys lead the primary key, so each is a primary-key probe. Measured against a loop ofReadObjectAsyncon a 250,000-row store (best of five, after warm-up):batch looped ReadObjectAsyncReadObjectsByKeysAsync200 1.9 ms 0.9 ms 999 10.6 ms 4.5 ms 4,949 36.8 ms 16.9 ms 23,784 183.2 ms 67.3 ms That is 2.1–2.7x end to end. Both figures include deserialization, which is identical between them and dominates what is left — the query alone is 27.5 ms at 23,784 keys. The
JSON_EACHshape was chosen by measurement: a singleIN (@p0…@pN)collapses at scale (1,297.7 ms at 23,784 keys, because the statement text and plan grow with the batch), a chunkedINis 91.8 ms, and a temp-table join carries ~40 ms of fixed setup. Keys not present are simply absent from the result. -
FilterType.InandFilterType.NotIn. Set membership as a single atomic term, via newFilteroverloads taking anIEnumerable:FilterBuilder<Item>.Create().Filter(FilterType.In, x => x.DepartmentId, new[] { 33, 47 });
It renders to
<path> IN (…)through the same numericCASTthe scalar comparisons use, so an expression index over the property still serves the query. Being one term, it cannot be mis-grouped the way anOr()chain can. Details:- Duplicate values are removed; the caller's order is preserved.
- An empty set matches nothing for
Inand everything forNotIn— neverIN (), which is a syntax error, and never a silently dropped term, which would widen the result set. - A
nullin the set is matched against a missing or null member withIS NULL, which SQL's ownINwould never do.NotInkeeps SQL's semantics for rows whose member is null: they are not returned, exactly asNotEqualsalready behaves. - Longer lists are split across several
INterms to keep eachIN (...)list reasonably sized. (Note: SQLite'sSQLITE_MAX_VARIABLE_NUMBERlimit is statement-wide; very large parameterized value sets (e.g., strings) can still exceed it on older builds.) - The raw-path overload takes
IEnumerable<object>rather than a generic parameter on purpose: a generic overload there captures an ordinarystringcomparison value, sincestringis anIEnumerable<char>. A value-type collection needsCast<object>(); the expression overload infers the element type from the property and needs no cast.
-
IJsonValueResolver. A second optional serializer capability, feature-detected the same way, reporting the scalar form a CLR value takes in JSON so filter comparisons are made against what was stored. Implemented bySystemTextJsonSerializerandNewtonsoftJsonSerializer; serializers that do not implement it fall back toToString(). -
IJsonPropertyNameResolver. An optional serializer capability (feature-detected, likeIUtf8JsonDeserializer) that reports the JSON member name a CLR property is serialized as. Implemented bySystemTextJsonSerializerandNewtonsoftJsonSerializer. Third-party serializers that do not implement it keep working unchanged, falling back to CLR property names. Resolved names are validated before being emitted into a JSON path, so a name carrying a quote is rejected withArgumentExceptionrather than escaping the SQL literal.
-
AddTypeRegistration<T>()on a type with a conventional id property now supplies a key. Previously every by-object operation on such a type threw; they now work. Code that caught that exception, or that relied onWriteObjectsAsync(objs, keySelector)disagreeing with a conventionally-namedIdproperty, changes behaviour — underrequireTypeRegistrationthe disagreement is now an error rather than a silently unreachable row. -
An ungrouped
Or()now means what it reads as. Code that (unknowingly) depended on the leaked rows — most plausibly a query written against a single-partition, single-type database where the bug was invisible — returns fewer rows now. This is the fix, not a regression. -
Passing a collection to a scalar
FilterTypenow throwsArgumentException. Adding theIEnumerableoverloads changes overload resolution for a collection argument, which previously bound toobjectand was rendered asToString()("System.Int32[]"), matching nothing silently. UseFilterType.In. A literalnullargument also now binds to the new overload, but keeps its old meaning —Filter(Equals, x => x.Value, null)is still the null comparison. -
Enum,
DateOnlyandTimeOnlyfilter values now compare against their JSON form. Code that worked around the enum mismatch by casting to(int)keeps working. Code that relied on a string-enum converter's name matching by coincidence also keeps working, and now stays correct when a naming policy renames the member. -
Property expressions now resolve to the serializer's JSON member names. Code using a naming policy or renaming attributes will start matching rows, sorting, and indexing correctly — but the emitted SQL paths change. Indexes created by earlier versions on the CLR-named path (e.g.
$.Description) are now unused and should be dropped and recreated. Applications that worked around the bug by storing PascalCase JSON while configuring a camelCase policy will see behavior change. -
Filter values are now bound, not concatenated. Values containing
',%,_, etc. are treated as literal data — correct behavior, but different from before for any code that (accidentally or intentionally) relied on the old concatenation. -
LIKEmetacharacters (%,_) inContains/StartsWith/EndsWithvalues now match literally; previously they acted as wildcards. -
The raw-string path/index-name overloads now throw
ArgumentExceptionfor inputs outside[A-Za-z0-9_.$\[\]](paths) /[A-Za-z0-9_](identifiers). -
Index DDL and physical index names changed. Indexes are rebuilt in the new partial shape the next time
CreateIndexis called for them, and the old index is dropped; no application change is required, but the first launch after upgrading pays a one-time rebuild. Code that inspected TychoDB's index names directly insqlite_mastermust account for the hash suffix — useListIndexes()instead. -
Sort SQL changed from
Data ->> '$.x'toJSON_EXTRACT(Data, '$.x')(andCAST(… as NUMERIC)for numeric properties). Ordering of scalar values is unchanged; this is what allows sorts to use an index. -
The three redundant
JsonValueindexes andidx_streamvalue_key_partitionare dropped on connect. Applications that created their own indexes with those exact names would lose them.
TychoDB.Encryptednow uses the same SQLite version asTychoDB. The encrypted build'sMicrosoft.Data.Sqlite.Corewas aligned to 9.0.8 (was 8.0.0) and the SQLCipher bundle bumped to 2.1.10 (was 2.1.4), so the encrypted package no longer ships an older SQLite engine than the standard one.- The serializer packages (
TychoDB.JsonSerializer,TychoDB.JsonSerializer.SystemTextJson,TychoDB.JsonSerializer.NewtonsoftJson) now multi-targetnetstandard2.1;net9.0. - The legacy
Tycho/ older-TFM (netstandard2.1;net7.0) package is not shipped in this release. Its shared source relies on net9-only APIs (System.Threading.Lock,FrozenDictionary) and it had not been building. Reviving it for Xamarin/MAUI (via portable-type fallbacks) is tracked as follow-up work.net9.0TychoDBandTychoDB.Encryptedare the supported packages.
-
Serializer choice is the largest remaining lever on read throughput. Reading a whole 250,000-row partition measured 254.7 ms with
SystemTextJsonSerializeragainst 358.9 ms withNewtonsoftJsonSerializer(~1.4x), because the former implementsIUtf8JsonDeserializerand receives rows as UTF-8 spans. Deserialization dominates any large read: of the 67.3 msReadObjectsByKeysAsynctakes for 23,784 keys, only 27.5 ms is the query. -
Performance guidance: prefer
WriteObjectsAsyncfor writing many objects — it is ~10× faster and ~6× lower-allocation than loopingWriteObjectAsync, andwithTransaction: trueis faster thanfalsefor bulk writes.