Skip to content

feat(blob): blob versioning support, opt-in per storage account (#665) - #2734

Open
Gavin Thompson (gavin-thompson-postman) wants to merge 10 commits into
Azure:mainfrom
gavin-thompson-postman:feature/blob-versioning
Open

feat(blob): blob versioning support, opt-in per storage account (#665)#2734
Gavin Thompson (gavin-thompson-postman) wants to merge 10 commits into
Azure:mainfrom
gavin-thompson-postman:feature/blob-versioning

Conversation

@gavin-thompson-postman

Copy link
Copy Markdown

Summary

Implements blob versioning for the LokiJS metadata store, opt-in per storage account and off by default. Fixes #665.

  • Versioning is an ARM management plane setting in Azure Storage (Microsoft.Storage/storageAccounts/blobServices/default#isVersioningEnabled), not part of the data plane REST API Azurite emulates — Set Blob Service Properties has no Versioning element. Since Azurite has no management plane, the setting is supplied at start up via two mutually exclusive options, --accountConfigFile <path> and --accountConfig <json>, mirrored by VS Code settings azurite.accountConfigFile / azurite.accountConfig. Multiple accounts are supported; an account that is not listed keeps the defaults, so this only ever opts accounts in.
  • No generated artifacts are edited. The existing swagger already defines the versionid query parameter on 15 operations and VersionId / IsCurrentVersion / HasVersionsOnly on BlobItemInternal, so the wire contract needed no codegen changes.
  • Version-creating operations follow the observed service behaviour: Put Blob, Put Block List, Copy Blob, Set Blob Metadata, Page/Append Blob Create, and Snapshot Blob create versions and return x-ms-version-id. Put Page and Append Block correctly do not.
  • Reads and deletes accept ?versionid=; x-ms-is-current-version is returned only for the current version. List Blobs supports include=versions. Tags and access tier are addressable per version. A version is restored by copying it over the current version.
  • Delete Blob without a version ID turns the current version into a previous version and retains it. Deleting the current version by ID is refused with 403 OperationNotAllowedOnRootBlob, matching the service.
  • Versioning can be turned on and off against an existing workspace, as on a real account. Turning it off keeps existing versions listed, readable and deletable by version ID.
  • Account configuration lives in a standalone src/common/account/ module with its own database file, so the queue and table services can share it rather than reading data owned by the blob service.

Implementation notes

  • Previous versions share the base blob's snapshot value in the blobs collection, so every lookup meaning "the blob itself" excludes them. Blobs written before versioning was enabled have no isCurrentVersion field, so those queries match isCurrentVersion !== false and keep resolving unchanged. versionId is part of the collection index, and ensureIndex runs for workspaces created before versioning existed.
  • Retention is a property of the document, not of the account setting: an existing current blob is demoted to a previous version whenever it is itself a version, and removed outright only when it is not. That one rule covers Put Blob, Put Block List, Copy Blob and Delete Blob, and is why turning versioning off does not destroy history.
  • List continuation tokens now carry a secondary key, because every version of a blob shares its name — resuming from a name alone skipped the remaining versions of the blob a page stopped inside. Tokens without a secondary key keep the historical plain-blob-name format, so listings that do not involve versions are unchanged and tokens issued by earlier versions of Azurite remain valid.
  • Version IDs match the service format (RFC 3339, 7-digit fractional seconds) and are generated with a single query for the newest existing version rather than probing candidate values.
  • Loose mode ignores a malformed or mutually exclusive versionid and serves the current version, rather than rejecting it — versionid was ignored entirely before versioning existed, so loose mode does not become stricter.
  • Versioning is not implemented for the SQL metadata store: configuring it with AZURITE_DB fails at start up, and a versionid request returns a not-implemented error rather than silently reading the current version.

Test plan

  • Full LokiJS suite: 920 passing, 4 pending (--grep @loki)
  • npm run test:upgrade (upgrade/persistence compatibility): 10 passing
  • --inMemoryPersistence mode: versioning suite passes, no database file written
  • npx eslint src/**/*.ts: clean
  • New suites: blob.versioning.test.ts, blob.versioning.hierarchy.test.ts (delimiter × versions, including page size 1 and a blob whose name is also another blob's prefix), blob.versioning.toggle.test.ts, tests/common/AccountModel.test.ts, tests/common/LokiAccountModelStore.test.ts
  • README updated with the new CLI flags and VS Code settings, Blob Versions moved out of the unsupported list, and a design doc added at docs/designs/blob-versioning.md
  • ChangeLog updated in Upcoming Release

Verified against a real storage account

The versioning suites run unchanged against real Azure via the existing AZURITE_LIVE_TEST_CONNECTION_STRING live mode (33 passing against a GPv2 account with versioning enabled; the versioning-disabled and loose-mode suites are skipped there). Setup is documented in the design doc.

This found four places where the implementation had followed the prose documentation but the service behaves differently, all now following the observed behaviour:

Documentation implies Service does
Set Blob Properties creates a version (every block blob write except Put Block) creates no version, returns no x-ms-version-id
Delete current version by ID permitted 403 OperationNotAllowedOnRootBlob
?snapshot= + ?versionid= 400 MutuallyExclusiveQueryParameters
HasVersionsOnly not reported under include=versions, only include=deletedwithversions

Notes

  • Existing behaviour is unchanged unless an account is explicitly configured with versioning enabled.
  • One open design question for the maintainers. Enable Blob versioning #665 notes that how management plane configuration should reach Azurite is still undecided. --accountConfigFile / --accountConfig is one answer; if the team prefers a different mechanism, the surface is contained to src/common/account/, the three environment classes and BlobServerFactory, and can be swapped without touching the versioning logic. Happy to change it.
  • The account configuration store lives in src/common/account/ so queue and table can share it, but the blob service owns its lifecycle for now as the only consumer. When another service reads it, ownership needs to move to the entry point and the instance be shared — two LokiJS instances autosaving one file would corrupt it. This is recorded on the field in BlobServer and in the design doc.
  • Not implemented and out of scope, documented rather than partially emulated: blob version SAS (sr=bv and the x permission), soft-delete interactions, blob expiration, change feed, object replication, and versionid on Get Block List / Get Page Ranges (the swagger does not define it on either).

Implements blob versioning for the LokiJS metadata store, opt in per storage
account. Fixes Azure#665.

Versioning is an ARM management plane setting in Azure Storage
(Microsoft.Storage/storageAccounts/blobServices/default#isVersioningEnabled),
not part of the data plane REST API that Azurite emulates. Since Azurite has no
management plane, the setting is supplied at start up through two new mutually
exclusive options, --accountConfigFile <path> and --accountConfig <json>, rather
than by inventing a data plane API for it. The account model lives in
src/common so queue and table can reuse it, and is persisted in its own Loki
collection.

The wire contract needed no code generation changes: the existing swagger
already defines the versionid query parameter on 15 operations and VersionId /
IsCurrentVersion on BlobItemInternal, so no generated artifact is hand edited.

Behaviour with versioning enabled for an account:

- Put Blob, Put Block List and Copy Blob retain the overwritten content as a
  previous version and return x-ms-version-id
- Get Blob and Get Blob Properties accept ?versionid=, and return
  x-ms-is-current-version only for the current version
- List Blobs supports include=versions, ordering versions of a blob oldest
  first with the current version last
- Delete Blob with ?versionid= removes a single version; deleting the current
  version retains previous versions rather than failing with SnapshotsPresent.
  Snapshots still block base blob deletion as before
- A version is restored by copying it over the current version, so a
  ?versionid= qualified copy source is now resolved
- ?snapshot= together with ?versionid= returns 400 InvalidQueryParameterValue,
  matching the real service error code

Previous versions share the base blob's snapshot value in the blobs collection,
so every lookup that means "the blob itself" now excludes them. Blobs written
before versioning was enabled have no isCurrentVersion field, so those queries
match isCurrentVersion !== false and continue to resolve unchanged.

Switching versioning on or off against an existing workspace is rejected at
start up: the persisted configuration is compared with the supplied one and
merged when there is no conflict. The resolved configuration is written to the
debug log to help diagnose issue reports.

Not implemented, and documented as such rather than partially emulated: the SQL
metadata store (configuring versioning with AZURITE_DB fails at start up, and a
versionid request returns a not-implemented error instead of silently reading
the current version), blob version SAS (sr=bv and the x permission), soft
delete interactions, blob expiration, Get Block List and Get Page Ranges with a
version ID (the storage swagger does not define versionid on either).

Adds tests/blob/apis/blob.versioning.test.ts covering the enabled and disabled
paths with List Blobs assertions alongside each version assertion, and
tests/common/AccountModel.test.ts for configuration parsing and validation.
Documents the feature in README.md and docs/designs/blob-versioning.md, and
moves Blob Versions out of the unsupported list in the support matrix.

Co-Authored-By: Claude <noreply@anthropic.com>
The list continuation token was the blob name alone, which cannot express
"resume from version N of blob X". Because listBlobs filtered with
`obj.name > marker`, following a continuation token skipped every remaining
version of the blob the previous page stopped inside.

Reproduced with five versions of a single blob and maxPageSize 2: the listing
returned 2 of 5 versions across 2 pages and then stopped, with no error - a
silently short result.

PageWithDelimiter now tracks a [name, secondaryKey] page key, where the
secondary key is the version ID when listing versions and empty otherwise, and
serializes it into the continuation token. A token with no secondary key keeps
the historical plain blob name format, so listings that do not involve versions
are unchanged and tokens issued by earlier Azurite versions stay valid.
Anything not recognizable as a composite token is read as a plain blob name.

Equal page keys are tolerated rather than rejected, because items sharing a
name do not always carry a secondary key - snapshots do not - so snapshot
listing keeps its existing behaviour.

listBlobs and listAllBlobs decode the incoming token, compare items against it
with the composite ordering, and sort by name then version ID then snapshot for
a deterministic order.

Adds three List Blobs pagination tests (versions of one blob, versions across
several blobs, and a non-versioned listing to show it is unaffected) and eight
unit tests for token encoding, decoding, and ordering. The pre-existing
PageWithDelimiter tests pass unchanged, which is what shows the non-versioned
path still behaves as before.

Co-Authored-By: Claude <noreply@anthropic.com>
Deleting a versioned blob without a version ID removed the current version's
document outright, so the content that was current at delete time was lost
permanently. Only the older versions survived.

Per the blob versioning documentation: "When you call the Delete Blob operation
without specifying a version ID, the current version becomes a previous version,
and there's no longer a current version. All existing previous versions of the
blob are preserved."

deleteBlob now demotes the current version to a previous version instead of
removing it, for both the plain delete and the deleteSnapshots=include cases.
An explicit delete of a single version by ID is unchanged and still removes that
version, and the non-versioned path still removes the blob outright.

Because the demoted document keeps isCurrentVersion false, reads and listings
that do not name a version already treat the blob as absent: Get Blob returns
404 and List Blobs omits it, while List Blobs with include=versions returns
every version with none marked current. Writing to the blob afterwards creates a
new current version and leaves the existing versions alone.

Also reports HasVersionsOnly on listed versions of a blob that has versions but
no current version, which is the documented signal for that state.

The previous test for this case asserted the buggy behaviour - that one version
survived a delete - so it passed while the content was being lost. It has been
replaced with assertions that both versions survive, that neither is current,
that the version which was current is still readable by ID, and that
HasVersionsOnly is set. Adds cases for writing after a delete, for
deleteSnapshots=include retaining versions while removing snapshots, and for a
single-version delete still removing the blob.

Co-Authored-By: Claude <noreply@anthropic.com>
Only Put Blob, Put Block List and Copy Blob created versions, so several
documented write operations silently did not. Per the reference behaviour, for
block blobs every write except Put Block creates a version, and for page and
append blobs Put Blob, Put Block List, Set Blob Metadata and Copy Blob do.

Added:

- Set Blob Metadata creates a version for every blob type and returns
  x-ms-version-id.
- Set Blob Properties creates a version for block blobs only. It deliberately
  does not return x-ms-version-id: the storage swagger declares that header on
  eleven operations and Set Blob Properties is not one of them, matching the
  real service.
- Page Blob Create and Append Blob Create return x-ms-version-id. The version
  was already being created by createBlob, only the header was missing.
- Snapshot Blob on a versioned blob now creates a new current version alongside
  the snapshot, and returns both x-ms-snapshot and x-ms-version-id.
- Copy Blob and Copy Blob From URL return the destination's new version ID.
- Get Blob Tags, Set Blob Tags and Set Blob Tier accept ?versionid=, so tags and
  access tier are addressable per version rather than silently applying to the
  current version.
- A malformed ?versionid= returns 400 InvalidQueryParameterValue rather than
  falling through to a 404.

Put Page and Append Block continue to create no version, which is the documented
exception for page and append blobs and is now covered by tests.

Modifying the current version is expressed by a new createNewCurrentVersion
helper: it copies the current document, demotes the original in place so it keeps
the old state, and gives the copy a fresh version ID. The copy inherits the lease
because a lease belongs to the blob rather than to a version.

setBlobHTTPHeaders and setBlobMetadata now return the new version ID alongside
the properties, and the two copy operations return a properties object widened
with an optional versionId, so the handlers can populate the response header.
The SQL store implements the same signatures and never produces a version ID,
since versioning is not supported there.

One existing test expectation was stale rather than wrong: deleting with
deleteSnapshots=include after a snapshot now leaves three versions instead of
two, because taking the snapshot itself creates one.

Co-Authored-By: Claude <noreply@anthropic.com>
Ran tests/blob/apis/blob.versioning.test.ts against a real GPv2 storage account
with versioning enabled, via the existing AZURITE_LIVE_TEST_CONNECTION_STRING
live mode. Four assertions failed, and in every case the implementation was
wrong rather than the test:

- Set Blob Properties does not create a version, for any blob type, and returns
  no x-ms-version-id. The prose documentation says every write on a block blob
  except Put Block creates a version, which is what the previous commit
  implemented, but the service disagrees. The swagger agrees with the service: it
  does not declare x-ms-version-id on this operation.
- Deleting the current version by version ID is refused with 403
  OperationNotAllowedOnRootBlob. Only a previous version may be targeted by
  version ID; the current version is removed by deleting the blob without one,
  which demotes it. Azurite previously hard deleted it.
- Combining ?snapshot= with ?versionid= returns 400
  MutuallyExclusiveQueryParameters, not InvalidQueryParameterValue. A malformed
  ?versionid= does return InvalidQueryParameterValue, which was already correct.
- HasVersionsOnly is not reported under include=versions, even for a blob whose
  current version has been deleted. It appears only under
  include=deletedwithversions, which depends on blob soft delete and is out of
  scope, so Azurite no longer emits it at all.

Adds StorageErrorFactory entries for MutuallyExclusiveQueryParameters (400) and
OperationNotAllowedOnRootBlob (403) with the messages the service returns.

The suite now passes both ways: 25 tests against real Azure and 28 against
Azurite, the difference being the three BlobVersioningDisabledAPIs cases which
are skipped in live mode because they assert versioning is off. That equivalence
is the point of the exercise, so the live mode setup is documented in
docs/designs/blob-versioning.md for the next person.

Co-Authored-By: Claude <noreply@anthropic.com>
Azurite refused to start when the account configuration flipped
isVersioningEnabled against an existing workspace, on the assumption that the
metadata would be left in a state matching neither setting. Verified against a
real storage account, that assumption is wrong and the toggle is well defined:

- Turning versioning off keeps existing versions listed under include=versions,
  readable by version ID, and deletable by version ID.
- A write while versioning is off produces a blob that is not a version, but the
  previously current version is still retained rather than destroyed. A blob can
  therefore hold versions plus a current blob that is not one.
- Turning versioning on over existing data is allowed. A blob written beforehand
  has no version ID until it is modified, at which point its prior state is
  captured as a version derived from its last modified time.

So the start up conflict is removed for this setting, and the persisted
configuration is updated rather than compared. The reconciliation machinery is
kept for future settings that genuinely cannot change once data exists and would
need a migration rather than a merge; that list is empty today.

The retention rule is now expressed as a property of the document rather than of
the account setting: an existing current blob is demoted to a previous version
whenever it is itself a version, and removed outright only when it is not. That
applies to Put Blob, Put Block List, Copy Blob and Delete Blob alike, and it is
why turning versioning off does not destroy history.

Adds tests/blob/apis/blob.versioning.toggle.test.ts, which restarts the server
against the same workspace with different account configuration in both
directions. That is how the emulator expresses an account level setting change,
so it is skipped in live mode. BlobTestServerFactory gained an optional
workspace name so two servers can share one metadata DB.

Co-Authored-By: Claude <noreply@anthropic.com>
Account configuration was a collection inside the blob metadata database, which
made it blob owned data even though the settings are per account. Queue and table
would have had to read through the blob service to reach it.

It now lives in src/common/account/ with its own database file,
__azurite_db_account__.json:

- AccountModel.ts, the model plus parsing and validation, moved here from
  src/common so the module is self contained. Deliberately not left under
  src/blob: a store in common should not depend on the blob layer.
- IAccountModelStore.ts, the contract - lifecycle, resolve(), and
  getBlobServiceConfig().
- LokiAccountModelStore.ts, the Loki implementation, including the reconciliation
  of start up configuration against the previous run and the currently empty list
  of settings that cannot be changed once a workspace holds data.
- index.ts as the module entry point.
- IAccountModelEnvironment names the accountModel() contract that BlobEnvironment,
  Environment and VSCEnvironment already satisfy.

LokiBlobMetadataStore now takes an IAccountModelStore and asks it whether
versioning is enabled, instead of owning a collection and the reconciliation
logic. It no longer needs a logger, since the only thing it logged was the
resolved account configuration, which the new store reports itself.

The blob service owns the store's lifecycle for now, as the only consumer:
BlobServer inits it before the metadata store, closes it after, and cleans its
database file alongside the others. When queue or table start reading account
configuration this ownership has to move to the entry point and the instance be
shared, because two Loki instances autosaving one file would corrupt it. That
constraint is recorded on the field in BlobServer and in the design doc rather
than left for someone to rediscover.

Adds tests/common/LokiAccountModelStore.test.ts covering defaults, case
insensitive account matching, persistence across runs, changing a persisted
setting, merging a new account into existing configuration, clean refusing to run
while open, and in memory mode writing no file.

Verified: 898 tests pass against Azurite (up from 887, the difference being the
new store tests), the versioning suite still passes in --inMemoryPersistence mode,
and the 25 test versioning suite still passes against a real storage account.

Co-Authored-By: Claude <noreply@anthropic.com>
The versioning tests only used listBlobsFlat, so the delimiter path had no
versioned coverage at all - despite PageWithDelimiter being the class changed for
the pagination fix. It both squashes names into BlobPrefix entries and carries the
continuation token, so a page can end part way through the versions of a blob that
is itself inside a squashed prefix. That combination was untested.

Adds tests/blob/apis/blob.versioning.hierarchy.test.ts with eight cases:

- hierarchical listing with include=versions emits each prefix exactly once,
  however many versions live underneath it
- the same listing without versions is unchanged
- listing inside a prefix returns every version of every blob under it
- paginated hierarchical listing emits no prefix twice and drops no version
- prefixes and blobs interleaved lexically, paged at size 1, which is the tightest
  case: every page holds a single prefix or a single blob version
- a blob whose name is also another blob's prefix ("p" as a blob and "p/" as a
  prefix) is listed separately from the prefix, keeping all its versions
- flat listing of the same interleaved tree at page size 1

Each case was run against a real storage account before being run against Azurite,
so the expectations are the service's behaviour rather than a restatement of this
implementation. Real Azure passes all eight and so does Azurite: no divergence
found.

That is a weaker result than the earlier live run, which found four divergences,
and it contradicts the expectation that the delimiter path would be where the
composite continuation token broke. It appears to hold because the marker records
the underlying blob's [name, versionId] key even when that blob was squashed into a
prefix, so resumption follows the same total ordering either way. The gap was still
worth closing: it sat directly on top of modified code and would otherwise have
remained an untested claim.

Co-Authored-By: Claude <noreply@anthropic.com>
…pers

Five things a reviewer would reasonably object to in the versioning work, all
found by reading the branch back as an adversarial reviewer.

versionId was a query dimension but not an index. Reads, deletes, tag and tier
operations all look blobs up by it, and generateVersionId probed for collisions,
so every one of those was an unindexed scan. It is now part of the blobs
collection index, and ensureIndex is called for a workspace created before
versioning existed so lookups stay indexed after an upgrade in place.

generateVersionId ran an unbounded loop, querying the database once per candidate
millisecond until it found a free value. Because version IDs share a fixed width
format, comparing them as strings orders them chronologically, so one query for
the newest existing version is enough: take the clock value if it is already past
it, otherwise step one millisecond beyond. Single query, bounded work, same
monotonic guarantee.

Loose mode was not honoured. Loose mode exists so Azurite ignores parameters it
would otherwise reject, and before versioning existed versionid was ignored
entirely, so rejecting a malformed one was loose mode becoming stricter than it
used to be. Simply swallowing the error was not enough - the request then looked
up a version that does not exist and returned 404 instead of 400. Validation and
sanitisation therefore live together in BaseHandler.resolveVersionId(), which
returns the version ID to serve the request with, or undefined in loose mode after
logging. Callers consume the return value rather than options.versionId, so a
caller cannot skip the error and then act on the bad input.

While converting those call sites, Set Blob Tier turned out to accept versionid
with no validation at all, so a malformed value reached the metadata store
directly. It is now validated like the rest.

The empty IRRECONCILABLE_BLOB_SERVICE_SETTINGS array and its filter are gone. A
conflict detection framework with nothing to detect is speculative; a comment now
records what a future setting needing a migration rather than a merge would have to
do, and the store interface no longer documents a throw that cannot happen.

demoteToPreviousVersion, createNewCurrentVersion and isVersionDoc took and
returned any. They now use BlobModel, with the cast narrowed to the two LokiJS
internals ($loki and meta) that are genuinely not part of the model, matching the
idiom already used in that file.

Adds a BlobVersioningLooseMode suite covering both the malformed version ID and
the snapshot/versionid combination. It runs as its own top level suite because the
test fixture binds a fixed port, so only one server can run at a time.

Verified: 920 tests pass against Azurite, 33 against a real storage account, and
the upgrade compatibility suite passes.

Co-Authored-By: Claude <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings August 13, 2026 15:09
@gavin-thompson-postman

Copy link
Copy Markdown
Author

@microsoft-github-policy-service agree company="Postman"

Copilot AI 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.

Pull request overview

Adds opt-in Azure Blob versioning emulation to Azurite’s LokiJS metadata implementation (off by default), including request/response surface changes (versionid, x-ms-version-id, x-ms-is-current-version) and updated listing semantics/pagination to correctly handle multiple versions per blob name.

Changes:

  • Introduces an account-level (management-plane-like) configuration model + Loki-backed store, wired into CLI and VS Code settings, to enable versioning per account.
  • Extends Loki blob metadata behavior to create/read/list/delete versions, including updated continuation token encoding to resume within a blob’s versions.
  • Adds extensive test coverage for versioning APIs, delimiter/hierarchy listing, loose mode behavior, and toggling versioning across restarts; updates docs/changelog.

Reviewed changes

Copilot reviewed 37 out of 37 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
tests/common/LokiAccountModelStore.test.ts Unit coverage for account-config persistence/merge behavior in Loki store.
tests/common/AccountModel.test.ts Unit coverage for parsing/validating account configuration and option resolution.
tests/BlobTestServerFactory.ts Test server setup extended to inject account model/store and workspace suffixing.
tests/blob/pagewithdelimiter.test.ts Adds token encoding/decoding and ordering tests for composite continuation markers.
tests/blob/apis/blob.versioning.toggle.test.ts End-to-end tests for toggling versioning across restarts on same workspace.
tests/blob/apis/blob.versioning.test.ts Main API suite covering version creation, reads, deletes, list behaviors, loose mode.
tests/blob/apis/blob.versioning.hierarchy.test.ts Tests delimiter/hierarchy listing correctness with versions and pagination.
src/common/VSCEnvironment.ts Adds VS Code settings support for account configuration (file/inline JSON).
src/common/utils/constants.ts Defines default Loki DB path for the account configuration store.
src/common/IAccountModelEnvironment.ts New environment interface for retrieving account model at startup.
src/common/EnvironmentFunctions.ts Shared helper to resolve/validate --accountConfigFile / --accountConfig.
src/common/Environment.ts Adds CLI flags for account configuration to the azurite entry point.
src/common/account/LokiAccountModelStore.ts New LokiJS-backed persistence for per-account configuration.
src/common/account/index.ts Barrel exports for account configuration module.
src/common/account/IAccountModelStore.ts Contract for resolving and reading per-account configuration.
src/common/account/AccountModel.ts Parsing/validation + types for account configuration, with defaults/errors.
src/blob/utils/utils.ts Adds shared validation for snapshot + versionid query parameters.
src/blob/persistence/SqlBlobMetadataStore.ts Explicitly rejects versioning requests against SQL metadata store.
src/blob/persistence/PageWithDelimiter.ts Adds composite continuation token support (name + secondary key).
src/blob/persistence/LokiBlobMetadataStore.ts Core Loki versioning logic: version IDs, retention, reads/lists/deletes/copies/tags/tier.
src/blob/persistence/IBlobMetadataStore.ts Extends metadata-store interfaces to carry version/toggle/listing semantics.
src/blob/IBlobEnvironment.ts Adds accountModel() to blob environment interface.
src/blob/handlers/PageBlobHandler.ts Returns x-ms-version-id for page blob create when enabled.
src/blob/handlers/ContainerHandler.ts Adds include=versions handling, plumbs includeVersions to metadata store.
src/blob/handlers/BlockBlobHandler.ts Returns x-ms-version-id for block blob writes when enabled.
src/blob/handlers/BlobHandler.ts Plumbs version resolution, adds version headers/behavior across operations.
src/blob/handlers/BaseHandler.ts Centralizes strict vs loose validation/behavior for versionid + snapshot.
src/blob/handlers/AppendBlobHandler.ts Returns x-ms-version-id for append blob create when enabled.
src/blob/errors/StorageErrorFactory.ts Adds new errors for mutually exclusive params and root-blob restrictions.
src/blob/BlobServerFactory.ts Initializes account config store and wires it into Blob server startup.
src/blob/BlobServer.ts Owns lifecycle for the account config store (init/close/clean).
src/blob/BlobEnvironment.ts Adds CLI flags for account config to azurite-blob entry point.
src/blob/BlobConfiguration.ts Adds account model store handle to blob configuration.
README.md Documents new CLI flags and VS Code settings; updates support matrix.
package.json Adds VS Code extension configuration schema for new settings.
docs/designs/blob-versioning.md Design doc describing model, behaviors, and non-goals.
ChangeLog.md Upcoming release notes updated for new versioning feature.
Suppressed comments (1)

src/blob/persistence/PageWithDelimiter.ts:219

  • PageWithDelimiter.add() enforces sorting by blob name only. With composite keys, passing items with the same name but a decreasing secondaryKey won’t throw, and can produce an incorrect continuation marker (skipping or duplicating results on resume).
    const [name, secondaryKey] = key;
    if (name < this.latestMarker[0]) {
      throw new Error("add received unsorted item. add must be called on sorted data");
    }

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +39 to +48
export function encodePageMarker(key: PageItemKey): string {
const [name, secondaryKey] = key;
if (secondaryKey === "") {
return name;
}
return (
COMPOSITE_MARKER_PREFIX +
Buffer.from(JSON.stringify([name, secondaryKey]), "utf8").toString("base64")
);
}
Comment thread ChangeLog.md Outdated
- Blob versioning can now be turned on and off against an existing workspace, matching the real service, rather than failing at start up. Turning it off keeps existing versions listed, readable and deletable by version ID, and a subsequent write retains the previously current version while producing a blob that is not itself a version. Turning it on over existing data captures a blob's prior state as a version when it is next modified.
- Aligned blob versioning with behaviour observed against a real storage account: `Set Blob Properties` does not create a version, deleting the current version by version ID returns 403 `OperationNotAllowedOnRootBlob`, combining `?snapshot=` with `?versionid=` returns 400 `MutuallyExclusiveQueryParameters`, and `HasVersionsOnly` is not reported under `include=versions`.
- Extended blob versioning to the remaining version creating operations: `Set Blob Metadata` (all blob types), `Set Blob Properties` (block blobs only), `Page Blob Create` and `Append Blob Create` now return `x-ms-version-id`, and `Snapshot Blob` on a versioned blob creates a new current version alongside the snapshot. `Put Page` and `Append Block` correctly do not create versions. `Get Blob Tags`, `Set Blob Tags` and `Set Blob Tier` accept `?versionid=`, and a malformed `?versionid=` returns 400 `InvalidQueryParameterValue`.
- `Delete Blob` on a versioned blob without a version ID now turns the current version into a previous version and retains it, rather than removing it, and reports `HasVersionsOnly` for a blob that has versions but no current version.
Comment thread src/blob/utils/utils.ts Outdated
Comment on lines +401 to +403
* A request may address a snapshot or a version, but not both. Azure Storage rejects
* the combination with 400 InvalidQueryParameterValue.
*
Comment thread tests/BlobTestServerFactory.ts Outdated
Comment on lines +89 to +90
// Blob versioning cannot be switched on or off against an existing workspace, so
// suites that configure it need their own metadata DB.
Comment thread ChangeLog.md Outdated

Blob:

- Added support for blob versioning on the LokiJS metadata store, opt in per storage account (issue #665). Versioning is an ARM management plane setting in Azure Storage, so it is supplied at start up with the new mutually exclusive `--accountConfigFile <path>` and `--accountConfig <json>` options rather than through a data plane API. With versioning enabled for an account: overwriting a blob retains the previous content as a version and returns `x-ms-version-id`; `Get Blob` / `Get Blob Properties` accept `?versionid=` and return `x-ms-is-current-version` for the current version; `List Blobs` supports `include=versions`; `Delete Blob` with `?versionid=` removes a single version, and deleting the current version retains previous versions instead of failing with `SnapshotsPresent`; a version can be restored by copying it over the current version. Combining `?snapshot=` with `?versionid=` returns 400 `InvalidQueryParameterValue`. The setting is persisted with the workspace and cannot be changed against existing data. Versioning is not implemented for the SQL metadata store, and blob version SAS (`sr=bv`, the `x` permission), soft delete interactions, and blob expiration interactions are out of scope - see `docs/designs/blob-versioning.md`.
Comment thread ChangeLog.md Outdated
- Moved account (management plane) configuration into a standalone `src/common/account/` module with its own database file (`__azurite_db_account__.json`), so that the queue and table services can share it rather than reading configuration owned by the blob service.
- Blob versioning can now be turned on and off against an existing workspace, matching the real service, rather than failing at start up. Turning it off keeps existing versions listed, readable and deletable by version ID, and a subsequent write retains the previously current version while producing a blob that is not itself a version. Turning it on over existing data captures a blob's prior state as a version when it is next modified.
- Aligned blob versioning with behaviour observed against a real storage account: `Set Blob Properties` does not create a version, deleting the current version by version ID returns 403 `OperationNotAllowedOnRootBlob`, combining `?snapshot=` with `?versionid=` returns 400 `MutuallyExclusiveQueryParameters`, and `HasVersionsOnly` is not reported under `include=versions`.
- Extended blob versioning to the remaining version creating operations: `Set Blob Metadata` (all blob types), `Set Blob Properties` (block blobs only), `Page Blob Create` and `Append Blob Create` now return `x-ms-version-id`, and `Snapshot Blob` on a versioned blob creates a new current version alongside the snapshot. `Put Page` and `Append Block` correctly do not create versions. `Get Blob Tags`, `Set Blob Tags` and `Set Blob Tier` accept `?versionid=`, and a malformed `?versionid=` returns 400 `InvalidQueryParameterValue`.
Review of the accumulated branch found five places where prose still described
behaviour that later commits reversed. No functional change.

The ChangeLog was the worst of it. As live testing corrected the implementation I
appended new bullets rather than editing the superseded ones, so the Upcoming
Release section contradicted itself: one bullet said the snapshot/versionid
combination returns InvalidQueryParameterValue and that the versioning setting
cannot be changed against existing data, another said Set Blob Properties creates
a version and returns x-ms-version-id, and a third claimed HasVersionsOnly is
reported - all three reversed by later bullets in the same list. A reader of the
release notes had no way to tell which won.

The blob section is now a single coherent description of the behaviour the branch
actually ships, organised by area rather than by the order I discovered things,
including an explicit list of what is not implemented. Seven accumulated bullets
became one entry with sub-bullets.

Also corrected:

- The JSDoc on validateSnapshotAndVersionId named InvalidQueryParameterValue for
  the snapshot/versionid combination; the function throws
  MutuallyExclusiveQueryParameters, which is what the service returns. The JSDoc
  now names both codes and which case each applies to.
- A comment in BlobTestServerFactory said blob versioning cannot be switched on or
  off against an existing workspace. That was true when the comment was written and
  is not any more, and the toggle tests deliberately share one workspace to prove
  it. The comment now explains what the per-suite database is actually for and how
  to share one on purpose.

Verified: 920 tests still pass against Azurite, build and lint clean.

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

Copilot AI 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.

Pull request overview

Copilot reviewed 37 out of 37 changed files in this pull request and generated 1 comment.

Suppressed comments (2)

src/blob/persistence/PageWithDelimiter.ts:43

  • encodePageMarker() returns the plain blob name when secondaryKey is empty. If a blob name itself starts with the composite marker prefix ("2!") and happens to be valid composite encoding, decodePageMarker() will misinterpret a historically-issued plain-name marker as composite and pagination can skip/duplicate items. Consider encoding names that start with the composite prefix even when there is no secondary key, to avoid collisions.
export function encodePageMarker(key: PageItemKey): string {
  const [name, secondaryKey] = key;
  if (secondaryKey === "") {
    return name;
  }

src/common/account/LokiAccountModelStore.ts:78

  • LokiAccountModelStore.init() treats any fs.stat() error as "file doesn't exist" and proceeds. This can mask real problems (e.g., permission errors) and lead to confusing downstream failures. Only ignore ENOENT; reject other stat errors.
      stat(this.lokiDBPath, (statError) => {
        if (!statError) {
          this.db.loadDatabase({}, (dbError) => {
            if (dbError) {
              reject(dbError);
            } else {
              resolve();
            }
          });
        } else {
          // when the DB file doesn't exist, ignore the error because the following will
          // re-create the file
          resolve();
        }
      });

Comment on lines +216 to 219
const [name, secondaryKey] = key;
if (name < this.latestMarker[0]) {
throw new Error("add received unsorted item. add must be called on sorted data");
}
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.

Enable Blob versioning

2 participants