feat(blob): blob versioning support, opt-in per storage account (#665) - #2734
Conversation
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>
|
@microsoft-github-policy-service agree company="Postman" |
There was a problem hiding this comment.
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.
| 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") | ||
| ); | ||
| } |
| - 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. |
| * A request may address a snapshot or a version, but not both. Azure Storage rejects | ||
| * the combination with 400 InvalidQueryParameterValue. | ||
| * |
| // Blob versioning cannot be switched on or off against an existing workspace, so | ||
| // suites that configure it need their own metadata DB. |
|
|
||
| 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`. |
| - 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>
There was a problem hiding this comment.
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();
}
});
| const [name, secondaryKey] = key; | ||
| if (name < this.latestMarker[0]) { | ||
| throw new Error("add received unsorted item. add must be called on sorted data"); | ||
| } |
Summary
Implements blob versioning for the LokiJS metadata store, opt-in per storage account and off by default. Fixes #665.
Microsoft.Storage/storageAccounts/blobServices/default#isVersioningEnabled), not part of the data plane REST API Azurite emulates —Set Blob Service Propertieshas noVersioningelement. 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 settingsazurite.accountConfigFile/azurite.accountConfig. Multiple accounts are supported; an account that is not listed keeps the defaults, so this only ever opts accounts in.versionidquery parameter on 15 operations andVersionId/IsCurrentVersion/HasVersionsOnlyonBlobItemInternal, so the wire contract needed no codegen changes.x-ms-version-id. Put Page and Append Block correctly do not.?versionid=;x-ms-is-current-versionis returned only for the current version.List Blobssupportsinclude=versions. Tags and access tier are addressable per version. A version is restored by copying it over the current version.Delete Blobwithout a version ID turns the current version into a previous version and retains it. Deleting the current version by ID is refused with 403OperationNotAllowedOnRootBlob, matching the service.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
snapshotvalue in the blobs collection, so every lookup meaning "the blob itself" excludes them. Blobs written before versioning was enabled have noisCurrentVersionfield, so those queries matchisCurrentVersion !== falseand keep resolving unchanged.versionIdis part of the collection index, andensureIndexruns for workspaces created before versioning existed.versionidand serves the current version, rather than rejecting it —versionidwas ignored entirely before versioning existed, so loose mode does not become stricter.AZURITE_DBfails at start up, and aversionidrequest returns a not-implemented error rather than silently reading the current version.Test plan
--grep @loki)npm run test:upgrade(upgrade/persistence compatibility): 10 passing--inMemoryPersistencemode: versioning suite passes, no database file writtennpx eslint src/**/*.ts: cleanblob.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.tsBlob Versionsmoved out of the unsupported list, and a design doc added atdocs/designs/blob-versioning.mdUpcoming ReleaseVerified against a real storage account
The versioning suites run unchanged against real Azure via the existing
AZURITE_LIVE_TEST_CONNECTION_STRINGlive 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:
x-ms-version-idOperationNotAllowedOnRootBlob?snapshot=+?versionid=MutuallyExclusiveQueryParametersHasVersionsOnlyinclude=versions, onlyinclude=deletedwithversionsNotes
--accountConfigFile/--accountConfigis one answer; if the team prefers a different mechanism, the surface is contained tosrc/common/account/, the three environment classes andBlobServerFactory, and can be swapped without touching the versioning logic. Happy to change it.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 inBlobServerand in the design doc.sr=bvand thexpermission), soft-delete interactions, blob expiration, change feed, object replication, andversionidon Get Block List / Get Page Ranges (the swagger does not define it on either).