Skip to content

feat(storage): per-collection required features, refused by versions that lack them (#187) - #194

Merged
mrdevrobot merged 1 commit into
EntglDb:mainfrom
CaffeinatedCoder:feat/187-required-features
Oct 13, 2026
Merged

mrdevrobot merged 1 commit into
EntglDb:mainfrom
CaffeinatedCoder:feat/187-required-features

Conversation

@CaffeinatedCoder

Copy link
Copy Markdown
Collaborator

Closes #187.

This adds the per-collection "required features" value discussed in #177. A collection records the features it depends on that older versions cannot maintain. A version that finds a feature it does not know refuses the collection instead of silently damaging it. The page format version stays unchanged, as agreed, so older versions still open the database. The PR defines no feature yet; full-text indexes (#177) will be the first. It is meant to be merged before any release that ships #177, so that every release from then on can refuse what it does not know.

Format

The record gets a new section after the index key formats, the last section today:

int32  count
count × { string id; byte requirement; string description }
  • id is a stable identifier that versions implementing the feature recognise, e.g. fulltext-index.
  • description is stored with the feature, e.g. full-text indexes (BLite 5.3 or later), so that a version that has never heard of the feature can still say what is missing and which version to use.
  • Compatibility in both directions:
    • Records without the section load with no features.
    • Releases that stop reading at the key formats load the record unchanged.
    • New sections go after this one.

Requirement levels

requirement Meaning Version without the feature
Open (1) A version without the feature would read the collection wrongly, e.g. serve queries from an index it misreads (the #177 planner issue). Refuses to open the collection.
Write (2) Reads are correct; writes would leave something out of date. Refuses unless the database is opened read-only (PageFileConfig.Access = Read), then opens it for reading.
anything else Reserved. Treated as Open.

I added both levels now rather than starting with one. A second level added later would be ignored by every version released in between: those versions would write to a collection that only needs to be protected from writers. So the distinction has to exist in the first version that checks.

Where it is enforced

  • Opening a collection. Both the typed path (CollectionIndexManager, so before DocumentCollection writes anything) and the dynamic path (DynamicCollection) throw NotSupportedException. The message names the collection, this version and every missing feature:

    Collection 'articles' requires features that BLite 5.2.3 does not support: full-text indexes (BLite 5.3 or later). Use a BLite version that supports these features.

    A DocumentDbContext that fails this way disposes its storage, as with any other open failure.

  • Writing metadata outside an open collection. SaveCollectionMetadata and DeleteCollectionMetadata check the record that is already stored, not the object passed in, and refuse to overwrite or drop it. FreeCollectionPages refuses too, because it can't find the pages of an index type it doesn't know. Nothing is written before these checks.

  • DocumentDbContext orphan cleanup. An unmapped collection that has unknown features is no longer dropped at open. It belongs to the newer version that created it, and dropping it would also leak the pages this version can't find.

  • BLiteEngine:

    • ListCollections lists such a collection without opening it. Before, one such collection would have made the whole listing throw.
    • VacuumAsync() over all collections refuses before vacuuming anything, since it could not erase that collection's deleted documents.
    • VacuumAsync with that CollectionName throws instead of returning silently.
    • TruncateCollectionAsync, migration (BLiteMigration) and GDPR subject export throw when they open it, rather than skipping a collection they can't read.
  • Index types. An index whose type is above the highest IndexType this version defines counts as an unknown Open feature (type 0 stays accepted). This keeps a new index type protected even after a release without this check has rewritten the record and dropped the section. When feat(index): full-text search with BM25 ranking (#70) #177 adds FullText = 6, the limit moves with the enum.

Limitations

Plan for #177

Once this is merged, #177 would:

  1. add fulltext-index to the known features;
  2. record new RequiredFeature("fulltext-index", Open, "full-text indexes (BLite 5.3 or later)") whenever a collection has a full-text index, and remove it when the last one is dropped (typed UpdateMetadata, dynamic PersistIndexMetadata);
  3. add tests for setting and clearing it.

The two cases you listed in #177 (an older DynamicCollection that drops the type-6 index from the metadata, and an older typed version that leaves stale postings on delete) are then refused by any release that has this PR. The branches merge without conflicts. On the merge, #177's full-text tests and these pass, and the index-type rule accepts type 6 as known.

Tests

RequiredFeaturesTests, 11 tests. A newer version is simulated by adding the feature to the stored metadata.

  • Persistence: the round trip keeps the id, the requirement (including an unknown value) and the description. A record cut back to the pre-section layout loads with no features and keeps every other field. The collection stays usable, and the next save writes an empty section.
  • Typed: the context refuses with a message naming the collection and the feature, and the files are released. An unmapped collection with an unknown feature is left alone, while a plain orphan is still dropped.
  • Engine:
    • opening, TruncateCollectionAsync and named vacuum throw;
    • ListCollections lists it;
    • vacuum over all collections refuses;
    • other collections stay readable and writable;
    • the record and pages are unchanged afterwards.
  • Export and migration: a GDPR subject export over all collections and BLiteMigration.ToMultiFileAsync throw instead of skipping the collection, and the source database is left unchanged.
  • Storage: SaveCollectionMetadata, FreeCollectionPages and DeleteCollectionMetadata throw and leave the record unchanged.
  • Levels:
    • a Write feature is refused read-write, with the message pointing to read-only, but opens and reads read-only;
    • an unknown level is refused even read-only and is named by its id when there is no description;
    • a message lists every unsupported feature.
  • Index types: an index of type 200 without any recorded feature is refused for typed and dynamic opens and is still listed.

Each check was disabled in turn: the open checks, the listing, orphan cleanup, the save/delete/free guards, named vacuum, the read-only flag, the unknown-level handling and the index-type rule. Each time, the matching tests fail.

Full suite on macOS: 2403 passed, 0 failed, 4 skipped. BLite.NetStandard21.Tests: 20/20. BLite.Core builds for net10.0 and netstandard2.1 with no warnings.

Checked against BLite.Core 5.2.3 (NuGet)

I used two scratch console apps, not part of the PR, one on this branch and one on the published 5.2.3 package:

Scenario Result
This branch writes a collection and records a feature; 5.2.3 opens it, lists, reads, inserts and creates an index Works. 5.2.3 ignores the section. Its metadata rewrite drops the section (the known limitation).
5.2.3 creates a database; this branch opens, reads and inserts; 5.2.3 opens it again Works both ways. 5.2.3 reads the record with the (empty) section.
This branch records an unknown feature, then opens the collection Refused with the message above.

Cost

The only per-operation cost is in SaveCollectionMetadata, which now parses the one stored record it has already located. 20,000 GetNextSequence calls (each a metadata read and save) took 7.38 s on this branch and 7.21 s on main, and later rounds were within noise. Both grow with every round because each save adds a slot to the metadata chain; that is independent of this PR.

Release notes (suggestion)

Collections can now record features they depend on. From this release on, a BLite version that does not know such a feature refuses to open or modify the collection, with an error naming the feature and the version that supports it, instead of damaging it. Releases before this one cannot refuse.

🤖 Generated with Claude Code

mrdevrobot
mrdevrobot previously approved these changes Oct 13, 2026

@mrdevrobot mrdevrobot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks, this is the right design. I checked that 5.2.x reads the collection record with a stream bounded by the slot length and stops after the key formats, so the trailing section is ignored; and I reproduced the compatibility table against NuGet 5.2.3. Refusal is per collection, before anything is written, and read-only opens accept Write-level features. With each check disabled in turn, 8, 1 and 1 of the 11 tests fail. Merged with main: BLite.Core builds with no warnings on both targets, full suite 2468 / 0 / 4 skipped, NetStandard21 20/20.

Approving. Three things to handle, none blocking:

  1. Conflict with #196. Both rewrite SaveCollectionMetadata. Git places this PR's guard after #196's new in-place write, where it no longer covers that path; with the guard there, Storage_RefusesToRewriteDeleteOrFree_ACollectionWithAnUnknownFeature fails. Whichever lands second should put the check right after FindMetadataRecord, before the in-place step, e.g. if (foundPage >= 0 && TryReadRecord(chain[foundPage].Buffer, ReadSlot(...)) is {} stored) EnsureCanWrite(stored);. Resolved that way, main + #194 + #196 passes the full suite (2474 / 0 / 4 skipped). I'd merge #196 first: its up-front size check also fixes the pre-existing case where an oversized record fails after the old one was marked deleted.
  2. The description overstates the index-type rule. 5.2.3's DynamicCollection.PersistIndexMetadata clears Indexes and rebuilds the list from the indexes it loaded, so a dynamic CreateIndexAsync or DropIndex in 5.2.3 drops both the feature section and the type-6 entry. I reproduced it: after a 5.2.3 dynamic CreateIndexAsync, features = 0 and this branch opens the collection without refusing. The rule only helps when the old writer keeps the index entry. Please reword the description and the CollectionFeatures remarks, and keep the "don't open with BLite < 5.3 for writing" release note from #177.
  3. Future index types. If a later index type adds per-index bytes inline in the index list (as Vector does with dims/metric), this version misreads the record, GetCollectionMetadata returns null, and the dynamic open creates a fresh record, so the refusal never runs. Please add the rule "new per-index data goes only in trailing sections" to the ReadCollectionMetadata and CollectionFeatures comments.

Nits: ListCollections now returns names that GetOrCreateCollection throws on, worth a release-note line; TryReadRecord swallows every exception, so an unparsable record skips the guard (consider refusing); check that the release pipeline stamps the assembly version used in the message (5.3.0, not 1.0.0).

@mrdevrobot

Copy link
Copy Markdown
Contributor

#196 is now merged (4250e29), so this branch conflicts in StorageEngine.Collections.cs as expected. Please rebase onto main and resolve it as described in my review: run the feature check right after FindMetadataRecord, before #196's in-place write, so it also covers that path. Storage_RefusesToRewriteDeleteOrFree_ACollectionWithAnUnknownFeature fails if the guard ends up after the in-place step. I'll merge once CI is green on the rebased branch.

…that lack them (EntglDb#187)

A collection can now record features it depends on that older versions
cannot maintain. A version that finds one it does not know refuses the
collection instead of silently damaging it.

Format: a new trailing section of the collection record, after the index key
formats: a count, then per feature its id, a requirement byte and a
description. Releases that stop reading at the key formats load the record
unchanged; records without the section load with no features.

Requirement levels:
- Open: needed to open the collection at all. Any unknown requirement value
  is treated this way.
- Write: needed only to modify it; the collection still opens when the
  database is opened read-only (PageFileConfig.Access = Read).

Checks:
- Opening a typed collection (CollectionIndexManager) or a dynamic one
  (DynamicCollection) throws NotSupportedException naming the collection,
  this version and each missing feature's description.
- SaveCollectionMetadata and DeleteCollectionMetadata refuse to rewrite or
  drop a stored record with unknown features; FreeCollectionPages refuses to
  free a collection whose pages it cannot find.
- An index whose type is newer than any this version knows counts as an
  unknown Open feature, so a new index type stays protected even after a
  release without this check rewrote the record and dropped the section.
- BLiteEngine.ListCollections lists such a collection without opening it,
  so one collection no longer makes the listing fail. VacuumAsync over all
  collections refuses before vacuuming anything (it could not erase that
  collection's deleted documents); VacuumAsync for that collection throws
  instead of returning silently.
- DocumentDbContext no longer drops an unmapped collection with unknown
  features at open; it leaves it for the version that created it.

No feature is defined yet; full-text indexes (EntglDb#177) will be the first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@mrdevrobot

Copy link
Copy Markdown
Contributor

I rebased this onto main (46918ee) since #196 is in. The only conflict was SaveCollectionMetadata in StorageEngine.Collections.cs. The feature check now runs right after FindMetadataRecord, before #196's in-place write, so it covers every write path. #196's save, compaction and TryReadRecordName are kept as on main. TryReadRecord now takes a SlotEntry. With the check disabled, Storage_RefusesToRewriteDeleteOrFree_ACollectionWithAnUnknownFeature fails; with it, the 11 RequiredFeaturesTests pass. BLite.Core builds with no warnings and the full suite gives 2480 / 0 / 4 skipped. Your authorship is kept on the commit. If you pull this branch locally, reset to the new head first.

@mrdevrobot
mrdevrobot merged commit 984b906 into EntglDb:main Oct 13, 2026
2 of 3 checks passed
CaffeinatedCoder added a commit to CaffeinatedCoder/BLite that referenced this pull request Oct 13, 2026
Brings in EntglDb#196 (squash-merged) and EntglDb#194 (per-collection required
features). StorageEngine.Collections.cs is main's version with the
follow-up re-applied: lock-free GetCollectionMetadata with a locked
re-walk on a miss, GetAllCollectionsMetadata under the lock,
compaction planned from slot lengths, AppendRecord(ReadOnlySpan<byte>).

DeleteCollectionMetadata (EntglDb#194's feature guard) now looks the record up
with FindCollectionMetadata: it already holds _metadataLock, which is
not reentrant, so the locked re-walk on a miss would wait for itself
until the lock timeout. Test added.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@CaffeinatedCoder

Copy link
Copy Markdown
Collaborator Author

The review follow-ups are in #219. A stored record that doesn't parse is now refused instead of overwritten. The CollectionFeatures remarks now describe the index-type rule accurately (5.2.3's dynamic PersistIndexMetadata drops the entry) and add the trailing-sections-only format rule. The release-note text is in that PR's description. The guard placement you used when merging with #196 is unchanged; EnsureCanReplaceRecord sits in the same spot.

CaffeinatedCoder added a commit to CaffeinatedCoder/BLite that referenced this pull request Oct 13, 2026
…ection (EntglDb#70)

Uses the per-collection required features from EntglDb#194, so BLite versions
that have that check but not full-text indexes refuse the collection
instead of misreading it.

- CollectionFeatures.FullTextIndexes: id "fulltext-index", description
  "full-text indexes (BLite 5.3 or later)", requirement Open. A version
  without full-text indexes reads the index as a B-Tree: its planner
  answers equality, range and IN lookups on the property from the
  postings, so even read-only results are wrong. It is a known feature
  in this version.
- SyncIndexFeatures records the feature while the collection has a
  full-text index and removes it with the last one, leaving every other
  feature alone. Called whenever the index list is written: typed
  UpdateMetadata (CreateFullTextIndexAsync, model-declared indexes via
  ApplyIndexBuilder, rebuilds, DropIndex) and dynamic
  PersistIndexMetadata.
- A read-write open records the feature again if a release that
  predates it rewrote the record and dropped the section (5.2.x does);
  a read-only open leaves the file alone.
- CollectionFeatures and ReadCollectionMetadata document that the
  index-type rule only helps while an old writer keeps the index entry
  (a 5.2.x dynamic collection drops both), and that new per-index data
  goes only in trailing sections.
- CreateFullTextIndexAsync (typed and dynamic) notes: do not open a
  database with full-text indexes for writing with BLite < 5.3.

Tests: the feature is recorded for model-declared, typed and dynamic
indexes, survives reopen, stays while another full-text index is left
and is cleared with the last one; it is recorded again on a read-write
open after being dropped, not on a read-only one; a version without
full-text indexes (known features and index types passed in) refuses to
open the collection even read-only, from the feature alone and from the
index type alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
mrdevrobot pushed a commit that referenced this pull request Oct 15, 2026
…nnot parse (#187) (#219)

Follow-up to the #194 review:

- SaveCollectionMetadata and DeleteCollectionMetadata now refuse a stored
  record that does not parse, with a NotSupportedException naming the
  collection, instead of skipping the feature check and overwriting it.
  An unparsable record made GetCollectionMetadata return null, so a
  dynamic open would save a fresh, empty record over it.
  EnsureCanReplaceRecord replaces TryReadRecord; the Save guard stays right
  after FindMetadataRecord, before the in-place step. The Delete guard
  uses LoadMetadataChain/FindMetadataRecord instead of
  GetCollectionMetadata, which skips unparsable records.
- CollectionFeatures remarks: the index-type rule protects a new index type
  only while the older release keeps the index entry; 5.2.3's dynamic
  PersistIndexMetadata (index create/drop, vacuum with RebuildIndexes)
  drops the entry together with the section.
- CollectionFeatures and ReadCollectionMetadata: new per-index data goes
  only in trailing sections, never inline in the index list, and why.
- Test: a record cut after its name is neither overwritten nor dropped,
  the dynamic open refuses, and other collections stay usable.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
mrdevrobot added a commit that referenced this pull request Oct 15, 2026
* fix(index): fill model-declared indexes from existing documents (#171)

An index declared in OnModelCreating was created empty when the collection
already held documents, so queries routed to it missed every older
document. This hit the usual way of adding an index: a new app version that
adds HasIndex, HasVectorIndex or HasSpatialIndex to an existing collection.

ApplyIndexBuilder now builds a missing index unregistered, fills it from the
primary index, and only then registers and persists it. A crash before that
leaves no empty index in the metadata, and the next open builds it again.
If a document cannot be loaded or indexed, the open fails with an
InvalidOperationException naming the index, and nothing is persisted.
Existing indexes are still a cheap name lookup on every open.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(index): address review of model-declared index backfill (#171)

- The error when an existing document cannot be indexed now says that
  nothing was saved and that every open fails the same way until the
  documents can be indexed or the index is removed from OnModelCreating,
  and includes the underlying message.
- Removes CollectionIndexManager.EnsureIndexUntyped and the public
  CreateVectorIndexUntyped / CreateSpatialIndexUntyped, which BuildDefinition
  made dead code.
- The CreateUnregistered doc comment notes that a spatial index cannot
  release its pages yet.

Opening a context whose model declares a new index over documents it
cannot index now throws; before, the index was created empty.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(index): address follow-ups on model-declared index backfill (#171)

- Restore the public CreateVectorIndexUntyped and CreateSpatialIndexUntyped
  on CollectionIndexManager, unchanged but marked [Obsolete]; the internal
  EnsureIndexUntyped stays removed.
- The backfill failure message ends the inner message with exactly one
  full stop before "Nothing was saved".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(index): free a partially built model index and roll back a failed registration (#171)

Address review of #175 on top of #170:

- ApplyIndexBuilder fills the new index through the same helper as the
  vacuum rebuild (FillUnregisteredIndexAsync): its own transaction, pages
  collected through it before the rollback, then freed. Before, a fill
  that failed after splits freed only the root, so every failing open
  leaked the rest of the partial tree and grew the file.
- PublishFilledIndex hands a filled index to Register or ReplaceIndex and
  frees it if it did not get registered; used by both callers.
- CollectionIndexManager: Register and ReplaceIndex share PublishLocked,
  which restores the previous registration (or none) when the metadata
  cannot be saved, and refreshes the unique-index flag. Register skipped
  that refresh, so a unique index added by the model was not enforced
  until the next open.
- CreateUnregisteredCopy is gone; callers use CreateUnregistered with the
  existing definition, which now checks for disposal.

Tests: a fill that fails on the last of 3000 documents does not grow the
file over repeated opens; a failed metadata save leaves the index
unregistered and frees its pages; a unique model index is enforced in the
session that adds it. All three fail without these changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(index): migrate legacy decimal indexes into a copy and free it on failure (#171)

MigrateLegacyDecimalIndexes replaced the index with an empty tree
(RecreateIndex) and then filled it in place. A rebuild that failed after
splits rolled back and left those pages linked from nowhere, and the next
open freed only the root of the emptied tree, so every failing open
leaked the partial tree and grew the file. The legacy tree was also gone
after the first failure.

The migration now uses the same helpers as model indexes and the vacuum
rebuild: it fills an unregistered copy (FillUnregisteredIndexAsync) set
to the current key format and swaps it in with ReplaceIndex through
PublishFilledIndex, so the new format and root are one metadata save.
On failure the copy is freed and the old index stays registered in its
old format with its data, and the next open retries.

RecreateIndex has no callers left and is removed.

Test: a legacy-format decimal index over 3000 documents whose last one
cannot be read; four failing opens do not grow the file, the stored
index keeps its root and legacy format, and the migration succeeds once
the document is repaired. Fails without this change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(index): full-text search with BM25 ranking (#70)

Adds a full-text index type for string properties, stored as a companion
inverted index in one B-Tree per index:

- postings [0x01][term][0x00][location][tf][doc length] -> location, so
  BM25 scores come from the postings alone and a delete recomputes the
  exact keys from the old text;
- one statistics entry [0x02][documents][terms], kept in the same
  transaction as the postings.

Text is analyzed with TextNormalizer (accents removed, lowercased,
punctuation as separators) and split into terms of at most 32 UTF-8 bytes.

API:
- HasFullTextIndex(x => x.Text) and CreateFullTextIndexAsync(x => x.Text)
  (default name idx_fts_{property}), filled from existing documents before
  the index is registered;
- LINQ x.Text.FullTextMatch("terms"): documents containing every term,
  served from the index and evaluated with the same rules in memory;
- FullTextSearchAsync(index, query, k, matchAllTerms) returning the top k
  documents with their BM25 score;
- DynamicCollection.CreateFullTextIndexAsync / FullTextSearchAsync.

Equality, range and IN lookups no longer pick a vector, spatial or
full-text index just because it covers the property.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(fts): address review of full-text search (#70)

- Tag postings 0x10 and statistics 0x20, outside the range of the generic
  [0x00..0x03] keys an older BLite writes when it reads the tree as a plain
  B-Tree; validate the statistics key length.
- Record FullTextIndex.FormatVersion as the index KeyFormat and rebuild the
  index on open (typed and dynamic) when it differs or the tree holds keys
  the index did not write, since deletes rely on reproducing the keys.
- Re-check every hit against the document text, for LINQ FullTextMatch and
  FullTextSearchAsync (typed and dynamic), so a stale posting never
  surfaces the document that now holds its location.
- Search with matchAllTerms intersects rarest-first before scoring, and
  falls back to statistics estimated from the postings when the entry is
  missing (e.g. lost to a concurrent transaction's update).
- CreateFullTextIndexAsync throws when the name belongs to a non-full-text
  index, passes its token to the rebuild, and is declared on
  IDocumentCollection; DynamicCollection.FullTextSearchAsync takes a token.
- Document the concurrent-transaction statistics caveat, batching inserts
  and the CJK / supplementary-plane limitation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(fts): batching advice and name-collision exception on CreateFullTextIndexAsync (#70)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(fts): a failed rebuild triggered by foreign keys is retried on the next open (#70)

A full-text index whose format is current but whose tree holds keys it did
not write (e.g. from an older BLite reading it as a plain B-Tree) is
rebuilt on open. MigrateLegacyDecimalIndexes (on #175's base) rebuilds it
into an unregistered copy and swaps that in only after it committed, so a
rebuild that fails leaves the old index registered as it was: still
holding the foreign keys, still needing the migration.

- MigrateLegacyDecimalIndexes doc comment covers full-text indexes.
- Test: corrupt a document so the rebuild on open fails; over three
  failing opens the stored root and format are unchanged, the foreign key
  is still there and the file does not grow; after repairing the document
  the next open swaps in the rebuilt index and a further open keeps it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(fts): return k re-checked hits and each location once (#70)

- FullTextSearchAsync (typed and dynamic) keeps taking candidates past
  the first k, in widening windows, until k hits survive the re-check
  against the document; the first window costs one Search as before.
- Postings yields one posting per location, so a stale posting sharing
  its term and location with the current document's no longer makes
  Match return the location twice or Search score it twice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(fts): rarest-first saves decoding and allocation, not I/O (#70)

CountRange walks every leaf entry of each query term, so looking up the
few candidates in a common term's postings avoids decoding them, not
reading their pages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(fts): a stale posting at a document's location can be the one scored (#70)

- Postings documents that the first posting of a location in key order
  (lowest frequency, then shortest length) is returned, which can be a
  stale one, so its frequency and length can be used for BM25 scoring,
  and CountRange counts both towards the document frequency. Matches
  and once-per-location results are unaffected.
- The test for a stale posting at a document's location notes that it
  checks membership and order, not scores.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(fts): free a failed dynamic full-text rebuild and keep the old index (#70)

DynamicCollection rebuilt a stale full-text index on open into a fresh
tree and, if a document could not be read or indexed, rolled back and
threw without freeing that tree. Pages added by splits are allocated
outside the transaction, so every failing open leaked the partial tree
and grew the file. CreateFullTextIndexAsync leaked the same way.

- FillUnregisteredFullTextIndexAsync fills the new index in its own
  transaction; on failure it collects the index's pages through the
  transaction before the rollback and frees them. Used by the rebuild on
  open and by CreateFullTextIndexAsync.
- PublishFullTextIndex registers the filled index and persists the
  metadata; if that fails it restores the previous registration and
  frees the new index.
- Each full-text index keeps its own KeyFormat and PersistIndexMetadata
  writes it, so persisting after one index's rebuild no longer marks
  another index that still awaits its rebuild as current (e.g. a crash
  between two rebuilds).

Test: a dynamic full-text index over 3000 documents with a foreign key
and an unreadable last document; four failing opens do not grow the
file and keep the old index (same root, still searchable, still flagged
for rebuild), and the rebuild succeeds once the document is repaired.
Fails without this change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(fts): record full-text indexes as a required feature of the collection (#70)

Uses the per-collection required features from #194, so BLite versions
that have that check but not full-text indexes refuse the collection
instead of misreading it.

- CollectionFeatures.FullTextIndexes: id "fulltext-index", description
  "full-text indexes (BLite 5.3 or later)", requirement Open. A version
  without full-text indexes reads the index as a B-Tree: its planner
  answers equality, range and IN lookups on the property from the
  postings, so even read-only results are wrong. It is a known feature
  in this version.
- SyncIndexFeatures records the feature while the collection has a
  full-text index and removes it with the last one, leaving every other
  feature alone. Called whenever the index list is written: typed
  UpdateMetadata (CreateFullTextIndexAsync, model-declared indexes via
  ApplyIndexBuilder, rebuilds, DropIndex) and dynamic
  PersistIndexMetadata.
- A read-write open records the feature again if a release that
  predates it rewrote the record and dropped the section (5.2.x does);
  a read-only open leaves the file alone.
- CollectionFeatures and ReadCollectionMetadata document that the
  index-type rule only helps while an old writer keeps the index entry
  (a 5.2.x dynamic collection drops both), and that new per-index data
  goes only in trailing sections.
- CreateFullTextIndexAsync (typed and dynamic) notes: do not open a
  database with full-text indexes for writing with BLite < 5.3.

Tests: the feature is recorded for model-declared, typed and dynamic
indexes, survives reopen, stays while another full-text index is left
and is cleared with the last one; it is recorded again on a read-write
open after being dropped, not on a read-only one; a version without
full-text indexes (known features and index types passed in) refuses to
open the collection even read-only, from the feature alone and from the
index type alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(storage): drop the format-rule note duplicated by #219's remarks

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: MrDevRobot <mrdevrobot@entgldb.com>

This branch was successfully deployed

1 active deployment
Production — 46918ee5 Deployed Oct 13, 2026 by mrdevrobot via build_and_publish #678
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.

Per-collection "required features" so older versions refuse collections they cannot maintain (full-text indexes)

2 participants