diff --git a/src/BLite.Core/BLiteEngine.cs b/src/BLite.Core/BLiteEngine.cs index b78e342..5beeb25 100644 --- a/src/BLite.Core/BLiteEngine.cs +++ b/src/BLite.Core/BLiteEngine.cs @@ -356,10 +356,20 @@ public IReadOnlyList ListCollections() // registered in the in-memory dictionary. DynamicCollection reads its // own metadata from storage, so the idType argument is irrelevant for // existing collections (only matters when creating brand-new ones). + // A collection that requires features this version cannot maintain is listed but + // not opened: opening it throws, and it must not make the whole listing fail. + var unopened = new List(); foreach (var meta in _storage.GetAllCollectionsMetadata()) + { + if (!_collections.ContainsKey(meta.Name) && !CollectionFeatures.CanOpen(meta, _storage.IsReadOnly)) + { + unopened.Add(meta.Name); + continue; + } _collections.GetOrAdd(meta.Name, n => new DynamicCollection(_storage, this, n, BsonIdType.ObjectId, _freeSpaceIndexes.GetIndex())); + } - return _collections.Keys.ToList(); + return _collections.Keys.Concat(unopened).ToList(); } /// @@ -532,18 +542,22 @@ public async Task VacuumAsync(VacuumOptions? options = null, CancellationToken c { // Ensure the collection is loaded from disk (it may exist on disk but not yet // in the in-memory map) before vacuum; silently skip if it doesn't exist at all. + // A collection ListCollections could not open still exists: opening it below + // reports the features this version lacks. ListCollections(); // warms up _collections from the on-disk catalog - if (!_collections.ContainsKey(options.CollectionName)) + if (!_collections.ContainsKey(options.CollectionName) + && _storage.GetCollectionMetadata(options.CollectionName) == null) return; targets = new[] { GetOrCreateCollection(options.CollectionName) }; } else { - // Warm up: load any on-disk collections not yet in the in-memory map, then - // snapshot the full set to avoid mutation during iteration. - ListCollections(); - targets = _collections.Values.ToArray(); + // Open every collection, including on-disk ones not yet in the in-memory map, and + // snapshot the set to avoid mutation during iteration. A collection that requires + // features this version lacks throws here, before anything is vacuumed: skipping it + // would leave its deleted documents unerased. + targets = ListCollections().Select(name => GetOrCreateCollection(name)).ToArray(); } foreach (var collection in targets) diff --git a/src/BLite.Core/DocumentDbContext.cs b/src/BLite.Core/DocumentDbContext.cs index 82043a3..31baa41 100644 --- a/src/BLite.Core/DocumentDbContext.cs +++ b/src/BLite.Core/DocumentDbContext.cs @@ -348,6 +348,10 @@ private void DropOrphanCollections() { if (registeredNames.Contains(metadata.Name)) continue; + // Created by a newer version with features this one cannot maintain: dropping it would + // destroy that version's data (and leak the pages this one cannot find), so leave it. + if (!CollectionFeatures.CanWrite(metadata)) continue; + // Orphan — free its pages, delete its metadata, and remove any per-collection file. _storage.FreeCollectionPagesCore(metadata.Name, _freeSpaceIndexes.InvalidatePages); _storage.DeleteCollectionMetadata(metadata.Name); diff --git a/src/BLite.Core/DynamicCollection.cs b/src/BLite.Core/DynamicCollection.cs index 7a40da0..67966dd 100644 --- a/src/BLite.Core/DynamicCollection.cs +++ b/src/BLite.Core/DynamicCollection.cs @@ -115,6 +115,10 @@ internal DynamicCollection(StorageEngine storage, ITransactionHolder transaction if (metadata != null) { + // Refuse a collection that requires features this version cannot maintain, before + // anything below writes to it. + CollectionFeatures.EnsureCanOpen(metadata, _storage.IsReadOnly); + primaryRootPageId = metadata.PrimaryRootPageId; _isTimeSeries = metadata.IsTimeSeries; diff --git a/src/BLite.Core/Indexing/CollectionIndexManager.cs b/src/BLite.Core/Indexing/CollectionIndexManager.cs index e64049b..7dddfb0 100644 --- a/src/BLite.Core/Indexing/CollectionIndexManager.cs +++ b/src/BLite.Core/Indexing/CollectionIndexManager.cs @@ -40,6 +40,10 @@ public CollectionIndexManager(StorageEngine storage, IDocumentMapper map // Load existing metadata via storage _metadata = _storage.GetCollectionMetadata(_collectionName) ?? new CollectionMetadata { Name = _collectionName }; + + // Refuse a collection that requires features this version cannot maintain, before anything + // below writes to it. + CollectionFeatures.EnsureCanOpen(_metadata, _storage.IsReadOnly); // Initialize indexes from metadata. Root page ids are fixed for the lifetime of an index // (a root split rewrites the root page in place), so they only need persisting when the diff --git a/src/BLite.Core/Storage/CollectionFeatures.cs b/src/BLite.Core/Storage/CollectionFeatures.cs new file mode 100644 index 0000000..be5ee42 --- /dev/null +++ b/src/BLite.Core/Storage/CollectionFeatures.cs @@ -0,0 +1,137 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using BLite.Core.Indexing; + +namespace BLite.Core.Storage; + +/// What a BLite version must support a for. +internal enum FeatureRequirement : byte +{ + /// + /// Needed to open the collection at all, because a version without the feature would read it + /// wrongly (e.g. serve queries from an index it misinterprets). Any value other than + /// , including ones added later, is treated this way. + /// + Open = 1, + + /// + /// Needed only to modify the collection: reading is correct without the feature, but writes + /// would leave the data it depends on out of date. A version without it may still open the + /// collection when the database is opened read-only. + /// + Write = 2, +} + +/// +/// A capability a collection depends on, recorded in its by the +/// version that started using it. +/// +/// Stable identifier that versions implementing the feature recognise. +/// Whether the feature is needed to open the collection or only to write to it. +/// +/// Human-readable name for error messages, including the first version that supports it, e.g. +/// "full-text indexes (BLite 5.3 or later)". Stored with the feature so that versions which +/// do not know it can still tell the user what is missing. +/// +internal sealed record RequiredFeature(string Id, FeatureRequirement Requirement, string Description); + +/// +/// Per-collection required features (#187). A collection that uses something older versions cannot +/// maintain (a new index type, a new document encoding, ...) records it in +/// ; a version that finds a feature it does not +/// know refuses to open or modify the collection instead of silently damaging it. +/// +/// +/// Only versions that contain this check can refuse; releases before it ignore the record and may +/// also drop it when they rewrite the collection's metadata. An index of a type newer than any this +/// version knows is refused the same way, so a new index type stays protected even after such a +/// release dropped the record. +/// +internal static class CollectionFeatures +{ + // Features this version implements. Empty until a feature that needs protecting ships. + private static readonly HashSet s_known = new(StringComparer.Ordinal); + + // Highest IndexType this version knows; larger values come from newer versions. 0 is accepted: + // older releases could persist an index whose type was never set. + private static readonly byte s_maxIndexType = +#if NET5_0_OR_GREATER + Enum.GetValues().Max(t => (byte)t); +#else + Enum.GetValues(typeof(IndexType)).Cast().Max(t => (byte)t); +#endif + + private static readonly string s_version = + typeof(CollectionFeatures).Assembly.GetName().Version?.ToString(3) ?? "0.0.0"; + + /// Whether this version implements the feature with the given id. + internal static bool IsKnown(string id) => s_known.Contains(id); + + /// + /// Whether this version may open the collection: every feature it requires is known, except + /// features when the database is open read-only. + /// + internal static bool CanOpen(CollectionMetadata metadata, bool readOnly) + => Unsupported(metadata, readOnly).Count == 0; + + /// Whether this version may modify the collection: every feature it requires is known. + internal static bool CanWrite(CollectionMetadata metadata) + => Unsupported(metadata, readOnly: false).Count == 0; + + /// Throws unless allows opening the collection. + /// The collection requires features this version does not support. + internal static void EnsureCanOpen(CollectionMetadata metadata, bool readOnly) + { + var unsupported = Unsupported(metadata, readOnly); + if (unsupported.Count == 0) return; + + throw unsupported.All(f => f.Requirement == FeatureRequirement.Write) + ? new NotSupportedException( + $"Collection '{metadata.Name}' requires features that BLite {s_version} cannot maintain: " + + $"{Describe(unsupported)}. Open the database read-only to read it, or use a BLite version " + + "that supports these features.") + : new NotSupportedException( + $"Collection '{metadata.Name}' requires features that BLite {s_version} does not support: " + + $"{Describe(unsupported)}. Use a BLite version that supports these features."); + } + + /// Throws unless allows modifying the collection. + /// The collection requires features this version does not support. + internal static void EnsureCanWrite(CollectionMetadata metadata) + { + var unsupported = Unsupported(metadata, readOnly: false); + if (unsupported.Count == 0) return; + + throw new NotSupportedException( + $"Collection '{metadata.Name}' requires features that BLite {s_version} does not support: " + + $"{Describe(unsupported)}. It cannot be modified by this version; use a BLite version that " + + "supports these features."); + } + + private static List Unsupported(CollectionMetadata metadata, bool readOnly) + { + var result = new List(); + foreach (var feature in metadata.RequiredFeatures) + { + if (IsKnown(feature.Id)) continue; + if (readOnly && feature.Requirement == FeatureRequirement.Write) continue; + result.Add(feature); + } + + // A version that does not know an index type cannot query or maintain the index, so it is + // treated as a feature needed to open the collection. + foreach (var index in metadata.Indexes) + { + var type = (byte)index.Type; + if (type > s_maxIndexType) + result.Add(new RequiredFeature($"index-type-{type}", FeatureRequirement.Open, + $"index '{index.Name}' of type {type}, which this version does not know")); + } + return result; + } + + private static string Describe(IEnumerable features) + => string.Join(", ", features.Select(f => + string.IsNullOrWhiteSpace(f.Description) ? $"'{f.Id}'" : f.Description)); +} diff --git a/src/BLite.Core/Storage/StorageEngine.Collections.cs b/src/BLite.Core/Storage/StorageEngine.Collections.cs index e7eabb4..c1c6c10 100644 --- a/src/BLite.Core/Storage/StorageEngine.Collections.cs +++ b/src/BLite.Core/Storage/StorageEngine.Collections.cs @@ -93,6 +93,15 @@ public class CollectionMetadata // ── Generalized Retention Policy ── public RetentionPolicy? GeneralRetentionPolicy { get; set; } + + // ── Required features ── + /// + /// Features this collection depends on that older versions cannot maintain; a version that + /// does not know one of them refuses to open or modify the collection (see + /// ). Persisted as stored, including features this version does + /// not know, so a rewrite of the record never drops them. + /// + internal List RequiredFeatures { get; } = new(); } public class IndexMetadata @@ -276,6 +285,19 @@ private static CollectionMetadata ReadCollectionMetadata(string name, BinaryRead metadata.Indexes[j].KeyFormat = format; } } + + // ── Required features (backward-compatible; absent = none) ──────── + if (reader.BaseStream.Position < reader.BaseStream.Length) + { + int featureCount = reader.ReadInt32(); + for (int j = 0; j < featureCount; j++) + { + var id = reader.ReadString(); + var requirement = (FeatureRequirement)reader.ReadByte(); + var description = reader.ReadString(); + metadata.RequiredFeatures.Add(new RequiredFeature(id, requirement, description)); + } + } return metadata; } @@ -363,6 +385,17 @@ public void SaveCollectionMetadata(CollectionMetadata metadata) foreach (var idx in metadata.Indexes) writer.Write(idx.KeyFormat); + // ── Required features (id, requirement, description) ────────────────── + // Written after every older section, so releases that stop reading at the key formats + // load the record unchanged. New sections go after this one. + writer.Write(metadata.RequiredFeatures.Count); + foreach (var feature in metadata.RequiredFeatures) + { + writer.Write(feature.Id); + writer.Write((byte)feature.Requirement); + writer.Write(feature.Description); + } + var newData = stream.ToArray(); if (newData.Length + SlotEntry.Size > PageSize - SlottedPageHeader.Size) throw new InvalidOperationException( @@ -373,6 +406,15 @@ public void SaveCollectionMetadata(CollectionMetadata metadata) throw new InvalidOperationException("Page 1 is not a collection metadata page."); var (foundPage, foundSlot) = FindMetadataRecord(chain, metadata.Name); + // Never rewrite the record of a collection that requires features this version lacks + // (see CollectionFeatures). Checked before every write path, the in-place one included. + // Nothing has been written yet. + if (foundPage >= 0 + && TryReadRecord(chain[foundPage].Buffer, ReadSlot(chain[foundPage].Buffer, foundSlot)) is { } stored) + { + CollectionFeatures.EnsureCanWrite(stored); + } + // ── 1. No larger than the stored record: overwrite it where it is ───── // A single page write. Covers the frequent saves (sequence values, root page ids) whose // record keeps its size, so they no longer leave a deleted record behind (#195). @@ -612,6 +654,21 @@ private static void WriteSlot(byte[] page, int slotIndex, SlotEntry slot) } } + /// Parses the collection record in , or returns null if it is malformed. + private static CollectionMetadata? TryReadRecord(byte[] page, SlotEntry slot) + { + try + { + using var ms = new MemoryStream(page, slot.Offset, slot.Length, false); + using var reader = new BinaryReader(ms); + return ReadCollectionMetadata(reader.ReadString(), reader); + } + catch + { + return null; + } + } + /// /// Marks the collection's metadata slot as deleted so that it no longer /// appears in or @@ -623,6 +680,11 @@ public void DeleteCollectionMetadata(string name) throw new TimeoutException("Timed out acquiring metadata lock (DeleteCollectionMetadata)."); try { + // Never drop the record of a collection that requires features this version lacks + // (see CollectionFeatures). + if (GetCollectionMetadata(name) is { } stored) + CollectionFeatures.EnsureCanWrite(stored); + var buffer = new byte[PageSize]; uint pageId = 1; diff --git a/src/BLite.Core/Storage/StorageEngine.Memory.cs b/src/BLite.Core/Storage/StorageEngine.Memory.cs index 4df25c6..b90b521 100644 --- a/src/BLite.Core/Storage/StorageEngine.Memory.cs +++ b/src/BLite.Core/Storage/StorageEngine.Memory.cs @@ -323,6 +323,10 @@ internal IReadOnlyCollection FreeCollectionPagesCore(string collectionName var metadata = GetCollectionMetadata(collectionName); if (metadata == null) return Array.Empty(); + // The pages of a feature this version lacks (e.g. an index type it does not know) cannot be + // found here; refuse instead of leaking them and dropping the rest. + CollectionFeatures.EnsureCanWrite(metadata); + // Collect page IDs in a set first, then free them all — avoids modifying storage // while iterating over B-tree nodes (which also read from the same storage). var toFree = new HashSet(); diff --git a/src/BLite.Core/Storage/StorageEngine.cs b/src/BLite.Core/Storage/StorageEngine.cs index b432f2b..c37c075 100644 --- a/src/BLite.Core/Storage/StorageEngine.cs +++ b/src/BLite.Core/Storage/StorageEngine.cs @@ -116,6 +116,14 @@ public sealed partial class StorageEngine : IDisposable internal LockTimeout LockTimeout => _config.LockTimeout; internal bool UsesSeparateCollectionFiles => _collectionFiles != null; + /// + /// Whether the database was opened read-only ( is + /// ). Collections that + /// require features this version cannot maintain may still be opened for reading then + /// (see ). + /// + internal bool IsReadOnly => _config.Access == System.IO.MemoryMappedFiles.MemoryMappedFileAccess.Read; + /// /// Returns the semaphore a must hold for /// write operations. In single-file mode every collection of this engine gets the same diff --git a/tests/BLite.Tests/RequiredFeaturesTests.cs b/tests/BLite.Tests/RequiredFeaturesTests.cs new file mode 100644 index 0000000..42d90b6 --- /dev/null +++ b/tests/BLite.Tests/RequiredFeaturesTests.cs @@ -0,0 +1,341 @@ +using System.IO.MemoryMappedFiles; +using BLite.Bson; +using BLite.Core; +using BLite.Core.GDPR; +using BLite.Core.Indexing; +using BLite.Core.Storage; +using BLite.Shared; + +namespace BLite.Tests; + +/// +/// Per-collection required features (#187): a collection that records a feature this version does +/// not know is refused instead of being opened, modified or dropped. The newer version that +/// recorded it is simulated by adding the feature to the stored metadata. +/// +public class RequiredFeaturesTests : IDisposable +{ + private readonly string _dbPath; + + private static readonly RequiredFeature FutureIndex = + new("future-index", FeatureRequirement.Open, "future indexes (BLite 99.0 or later)"); + + private static readonly RequiredFeature FutureWriteOnly = + new("future-stats", FeatureRequirement.Write, "future statistics (BLite 99.0 or later)"); + + public RequiredFeaturesTests() + { + _dbPath = Path.Combine(Path.GetTempPath(), $"blite_required_features_{Guid.NewGuid():N}.db"); + } + + public void Dispose() + { + foreach (var path in new[] { _dbPath, Path.ChangeExtension(_dbPath, ".wal"), _dbPath + "-shm", Path.ChangeExtension(_dbPath, ".wal-shm") }) + { + try { if (File.Exists(path)) File.Delete(path); } catch { } + } + } + + /// Records on the collection, as a newer version would. + private void RequireFeatures(string collection, params RequiredFeature[] features) + { + using var storage = new StorageEngine(_dbPath, PageFileConfig.Default); + var meta = storage.GetCollectionMetadata(collection) ?? new CollectionMetadata { Name = collection }; + meta.RequiredFeatures.AddRange(features); + storage.SaveCollectionMetadata(meta); + } + + private CollectionMetadata ReadMetadata(string collection) + { + using var storage = new StorageEngine(_dbPath, PageFileConfig.Default); + return storage.GetCollectionMetadata(collection)!; + } + + private async Task SeedUsersAsync() + { + using var db = new TestDbContext(_dbPath); + await db.Users.InsertAsync(new User { Name = "Ada", Age = 36 }); + await db.Users.InsertAsync(new User { Name = "Grace", Age = 45 }); + } + + private async Task SeedDynamicAsync(params string[] collections) + { + using var engine = new BLiteEngine(_dbPath); + foreach (var name in collections) + await engine.InsertAsync(name, engine.CreateDocument(["name"], b => b.AddString("name", name + "-1"))); + } + + // ── Persistence ────────────────────────────────────────────────────────── + + [Fact] + public async Task RequiredFeatures_RoundTrip_IncludingRequirementsThisVersionDoesNotKnow() + { + await SeedDynamicAsync("items"); + var later = new RequiredFeature("later-kind", (FeatureRequirement)7, ""); + RequireFeatures("items", FutureIndex, FutureWriteOnly, later); + + var meta = ReadMetadata("items"); + + Assert.Equal(new[] { FutureIndex, FutureWriteOnly, later }, meta.RequiredFeatures); + } + + [Fact] + public async Task RecordWithoutTheSection_AsOlderReleasesWriteIt_LoadsWithNoFeatures_AndTheSectionIsAppended() + { + await SeedDynamicAsync("items"); + RequireFeatures("items", FutureIndex); + var withFeature = ReadMetadata("items"); + + // Strip the section from the stored record: what remains is exactly the record a release + // without required features writes, and exactly the bytes such a release reads. + int sectionLength; + using (var ms = new MemoryStream()) + using (var writer = new BinaryWriter(ms)) + { + writer.Write(1); + writer.Write(FutureIndex.Id); + writer.Write((byte)FutureIndex.Requirement); + writer.Write(FutureIndex.Description); + writer.Flush(); + sectionLength = (int)ms.Length; + } + ShortenRecord("items", sectionLength); + + var older = ReadMetadata("items"); + Assert.Empty(older.RequiredFeatures); + Assert.Equal(withFeature.PrimaryRootPageId, older.PrimaryRootPageId); + Assert.Equal(withFeature.Indexes.Count, older.Indexes.Count); + Assert.Equal(withFeature.SequenceValue, older.SequenceValue); + + // The collection opens and stays usable, and the next save writes an empty section. + using (var engine = new BLiteEngine(_dbPath)) + { + var items = engine.GetOrCreateCollection("items"); + await items.InsertAsync(engine.CreateDocument(["name"], b => b.AddString("name", "items-2"))); + await items.CreateIndexAsync("name"); + Assert.Equal(2, await items.FindAllAsync().CountAsync()); + } + Assert.Empty(ReadMetadata("items").RequiredFeatures); + } + + /// Removes the last bytes of the collection's stored record. + private void ShortenRecord(string collection, int bytes) + { + using var storage = new StorageEngine(_dbPath, PageFileConfig.Default); + var page = new byte[storage.PageSize]; + uint pageId = 1; + while (pageId != 0) + { + storage.ReadPage(pageId, null, page); + var header = SlottedPageHeader.ReadFrom(page); + for (ushort i = 0; i < header.SlotCount; i++) + { + var offset = SlottedPageHeader.Size + i * SlotEntry.Size; + var slot = SlotEntry.ReadFrom(page.AsSpan(offset)); + if ((slot.Flags & SlotFlags.Deleted) != 0) continue; + using var reader = new BinaryReader(new MemoryStream(page, slot.Offset, slot.Length, false)); + if (reader.ReadString() != collection) continue; + + slot.Length -= (ushort)bytes; + slot.WriteTo(page.AsSpan(offset)); + storage.WritePageImmediate(pageId, page); + return; + } + pageId = header.NextOverflowPage; + } + throw new InvalidOperationException($"No record for '{collection}'."); + } + + // ── Typed collections ──────────────────────────────────────────────────── + + [Fact] + public async Task TypedContext_RefusesToOpen_ACollectionWithAnUnknownFeature() + { + await SeedUsersAsync(); + RequireFeatures("users", FutureIndex); + + var ex = Assert.Throws(() => new TestDbContext(_dbPath)); + Assert.Contains("'users'", ex.Message); + Assert.Contains("future indexes (BLite 99.0 or later)", ex.Message); + + // The failed open released the files and changed nothing. + var meta = ReadMetadata("users"); + Assert.Equal(new[] { FutureIndex }, meta.RequiredFeatures); + } + + [Fact] + public async Task TypedContext_LeavesAnUnmappedCollectionWithAnUnknownFeature_InsteadOfDroppingIt() + { + await SeedUsersAsync(); + await SeedDynamicAsync("written_by_newer", "plain_orphan"); + RequireFeatures("written_by_newer", FutureIndex); + + using (var db = new TestDbContext(_dbPath)) + Assert.Equal(2, await db.Users.AsQueryable().CountAsync()); + + using var storage = new StorageEngine(_dbPath, PageFileConfig.Default); + Assert.NotNull(storage.GetCollectionMetadata("written_by_newer")); + Assert.Null(storage.GetCollectionMetadata("plain_orphan")); // unchanged behaviour for the rest + } + + // ── Dynamic collections and the engine ─────────────────────────────────── + + [Fact] + public async Task Engine_RefusesTheCollection_AndKeepsTheOthersUsable() + { + await SeedDynamicAsync("future", "items"); + RequireFeatures("future", FutureIndex); + + using var engine = new BLiteEngine(_dbPath); + + var ex = Assert.Throws(() => engine.GetOrCreateCollection("future")); + Assert.Contains("'future'", ex.Message); + Assert.Contains("future indexes (BLite 99.0 or later)", ex.Message); + await Assert.ThrowsAsync(() => engine.TruncateCollectionAsync("future")); + await Assert.ThrowsAsync(() => + engine.VacuumAsync(new VacuumOptions { CollectionName = "future" })); + + // Listing names it without opening it. Vacuuming everything refuses before touching any + // collection, since it could not erase this one's deleted documents. + var names = engine.ListCollections(); + Assert.Contains("future", names, StringComparer.OrdinalIgnoreCase); + Assert.Contains("items", names, StringComparer.OrdinalIgnoreCase); + await Assert.ThrowsAsync(() => engine.VacuumAsync()); + + var items = engine.GetOrCreateCollection("items"); + await items.InsertAsync(engine.CreateDocument(["name"], b => b.AddString("name", "items-2"))); + Assert.Equal(2, await items.FindAllAsync().CountAsync()); + + engine.Dispose(); + var meta = ReadMetadata("future"); + Assert.Equal(new[] { FutureIndex }, meta.RequiredFeatures); + Assert.NotEqual(0u, meta.PrimaryRootPageId); + } + + [Fact] + public async Task SubjectExportAndMigration_Refuse_InsteadOfSkippingTheCollection() + { + await SeedDynamicAsync("future", "items"); + RequireFeatures("future", FutureIndex); + + using (var engine = new BLiteEngine(_dbPath)) + { + var query = new SubjectQuery { FieldName = "name", FieldValue = BsonValue.FromString("future-1") }; + await Assert.ThrowsAsync(() => engine.ExportSubjectDataAsync(query)); + } + + // Migrate a copy, so the multi-file layout lands in a directory of its own. + var dir = Path.Combine(Path.GetTempPath(), $"blite_required_features_mig_{Guid.NewGuid():N}"); + Directory.CreateDirectory(dir); + try + { + var source = Path.Combine(dir, "source.db"); + File.Copy(_dbPath, source); + if (File.Exists(Path.ChangeExtension(_dbPath, ".wal"))) + File.Copy(Path.ChangeExtension(_dbPath, ".wal"), Path.ChangeExtension(source, ".wal")); + + var target = PageFileConfig.Server(source); + Directory.CreateDirectory(Path.GetDirectoryName(target.WalPath!)!); + Directory.CreateDirectory(target.CollectionDataDirectory!); + await Assert.ThrowsAsync(() => BLiteMigration.ToMultiFileAsync(source, target)); + + using var storage = new StorageEngine(source, PageFileConfig.Default); + Assert.Equal(new[] { FutureIndex }, storage.GetCollectionMetadata("future")!.RequiredFeatures); + } + finally + { + try { Directory.Delete(dir, recursive: true); } catch { } + } + } + + [Fact] + public async Task Storage_RefusesToRewriteDeleteOrFree_ACollectionWithAnUnknownFeature() + { + await SeedDynamicAsync("future"); + RequireFeatures("future", FutureIndex); + + using var storage = new StorageEngine(_dbPath, PageFileConfig.Default); + var meta = storage.GetCollectionMetadata("future")!; + meta.SequenceValue = 42; + + Assert.Throws(() => storage.SaveCollectionMetadata(meta)); + Assert.Throws(() => storage.FreeCollectionPages("future")); + Assert.Throws(() => storage.DeleteCollectionMetadata("future")); + + var stored = storage.GetCollectionMetadata("future")!; + Assert.Equal(0, stored.SequenceValue); + Assert.Equal(new[] { FutureIndex }, stored.RequiredFeatures); + } + + [Fact] + public async Task IndexOfATypeThisVersionDoesNotKnow_IsRefused_EvenWithoutARecordedFeature() + { + // What a newer index type looks like after a release without required features rewrote + // the record: the index is still listed, its feature is gone. 200 stands for any type + // added after this version. + await SeedUsersAsync(); + using (var storage = new StorageEngine(_dbPath, PageFileConfig.Default)) + { + var meta = storage.GetCollectionMetadata("users")!; + meta.Indexes.Add(new IndexMetadata { Name = "idx_future_Name", Type = (IndexType)200, PropertyPaths = ["Name"], RootPageId = 0 }); + storage.SaveCollectionMetadata(meta); + } + + var ex = Assert.Throws(() => new TestDbContext(_dbPath)); + Assert.Contains("index 'idx_future_Name' of type 200", ex.Message); + + using var engine = new BLiteEngine(_dbPath); + Assert.Throws(() => engine.GetOrCreateCollection("users")); + Assert.Contains("users", engine.ListCollections(), StringComparer.OrdinalIgnoreCase); + } + + // ── Requirement levels ─────────────────────────────────────────────────── + + [Fact] + public async Task WriteOnlyFeature_IsRefusedForWriting_ButOpensReadOnly() + { + await SeedDynamicAsync("stats"); + RequireFeatures("stats", FutureWriteOnly); + + using (var engine = new BLiteEngine(_dbPath)) + { + var ex = Assert.Throws(() => engine.GetOrCreateCollection("stats")); + Assert.Contains("future statistics (BLite 99.0 or later)", ex.Message); + Assert.Contains("read-only", ex.Message); + } + + var readOnly = PageFileConfig.Default with { Access = MemoryMappedFileAccess.Read, AllowMultiProcessAccess = true }; + using (var engine = new BLiteEngine(_dbPath, readOnly)) + { + var docs = await engine.GetOrCreateCollection("stats").FindAllAsync().ToListAsync(); + Assert.Single(docs); + } + } + + [Fact] + public async Task UnknownRequirementLevel_IsTreatedAsNeededToOpen_EvenReadOnly() + { + await SeedDynamicAsync("later"); + RequireFeatures("later", new RequiredFeature("later-kind", (FeatureRequirement)7, "")); + + var readOnly = PageFileConfig.Default with { Access = MemoryMappedFileAccess.Read, AllowMultiProcessAccess = true }; + using var engine = new BLiteEngine(_dbPath, readOnly); + + var ex = Assert.Throws(() => engine.GetOrCreateCollection("later")); + Assert.Contains("'later-kind'", ex.Message); // no description: named by id + } + + [Fact] + public async Task Message_ListsEveryUnsupportedFeature() + { + await SeedDynamicAsync("future"); + RequireFeatures("future", FutureIndex, FutureWriteOnly); + + using var engine = new BLiteEngine(_dbPath); + var ex = Assert.Throws(() => engine.GetOrCreateCollection("future")); + + Assert.Contains("future indexes (BLite 99.0 or later)", ex.Message); + Assert.Contains("future statistics (BLite 99.0 or later)", ex.Message); + Assert.DoesNotContain("read-only", ex.Message); // one of them is needed even to read + } +}