Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Experimental support for taking and rolling back snapshots of containers #94

Draft
wants to merge 5 commits into
base: develop
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/short-berries-behave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@freshgum/typedi': minor
---

Experimental support for taking snapshots of individual containers has been added through a new SnapshotHost contrib feature, which also supports rolling back changes made to a container in a certain period.
122 changes: 122 additions & 0 deletions src/contrib/snapshot/immutable-map.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
export class ImmutableMap<K, V> extends Map<K, V> implements Map<K, V> {
constructor (private innerMap: Map<K, V>) {
/** Note that instead of copying the inner-map, we shadow it for additional performance. */
super();
}

/**
* A set of keys which have been deleted by consumers of this map.
*
* If a key is in this list, the map pretends as if it does not know about it.
* This is to ensure it remains spec-compliant with the original {@link Map} implementation.
*/
private deletedKeys = new Set<K>();

get(key: K): V | undefined {
if (this.deletedKeys.has(key)) {
return;
}

if (this.has(key)) {
return super.get(key);
}

return this.innerMap.get(key);
}

has(key: K): boolean {
return !this.deletedKeys.has(key) && (this.has(key) || this.innerMap.has(key));
}

delete(key: K): boolean {
/**
* Note that instead of deleting the key from the inner-map, we just add
* it to a set of keys that we've marked as deleted, so we know never to
* return them to the caller unless they set a new value for them.
*
* This mirrors the implementation of a native Map while ensuring we
* keep a performant implementation.
*/
if (this.deletedKeys.has(key)) {
return false;
}

this.deletedKeys.add(key);
return true;
}

set(key: K, value: V): this {
if (this.deletedKeys.has(key)) {
this.deletedKeys.delete(key);
}

return super.set(key, value);
}

clear() {
this.forEach((_, key) => this.deletedKeys.add(key));
}

*entries(): IterableIterator<[K, V]> {
const shown = new Set<K>();

for (const entry of this.entries()) {
const [key] = entry;
shown.add(key);

if (!this.deletedKeys.has(key)) {
yield entry;
}
}

for (const entry of this.innerMap.entries()) {
/** As this map shadows the inner-map, we ensure we don't yield a key more than once. */
const [key] = entry;

if (shown.has(key)) {
continue;
}

if (!this.deletedKeys.has(key)) {
yield entry;
}
}
}

*keys(): IterableIterator<K> {
/** Re-use the entries implementation, as we've done the shadowing work there. */
for (const [key] of this.entries()) {
yield key;
}
}

*values(): IterableIterator<V> {
/** Re-use the entries implementation, as we've done the shadowing work there. */
for (const [, value] of this.entries()) {
yield value;
}
}

*[Symbol.iterator]() {
return this.entries();
}

forEach(callbackfn: (value: V, key: K, map: Map<K, V>) => void, thisArg?: any): void {
for (const [key, value] of this.entries()) {
callbackfn.call(thisArg, value, key, this);
}
}

get size () {
/** TODO: This could use some optimization. */
let totalSize = 0;
const iterator = this.keys();

// eslint-disable-next-line @typescript-eslint/no-unused-vars
while (iterator.next().done === false) {
totalSize++;
}

return totalSize;
}
}
131 changes: 131 additions & 0 deletions src/contrib/snapshot/snapshot-host.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { ContainerInstance } from '../../container-instance.class.mjs';
import { ServiceIdentifier, ServiceMetadata } from '../../index.mjs';
import { ManyServicesMetadata } from '../../interfaces/many-services-metadata.interface.mjs';
import { ContainerInternals } from '../util/container-internals.util.mjs';
import { ImmutableMap } from './immutable-map.mjs';

/**
* An implementation of a snapshot mechanism for TypeDI-compatible container implementations.
*
* This host allows you to enter periods wherein changes to the container
* are staged, and they can easily be rolled back at a later date.
*
* @example
* ```ts
* // Create a new container.
* const myContainer = Container.ofChild(Symbol());
*
* // Create a snapshot host to allow us to create and rollback changes.
* const snapshotHost = new SnapshotHost(myContainer);
*
* // Make some changes to the container outside of a snapshot.
* const NAME = new Token<string>();
* const AGE = new Token<number>();
* myContainer.set({ id: NAME, value: 'Joanna' });
*
* // Briefly enter a snapshot period...
* snapshotHost.beginSnapshot();
* myContainer.set({ id: NAME, value: 'Roxy' });
* myContainer.set({ id: AGE, value: 25 });
* snapshotHost.endSnapshot();
*
* myContainer.getOrNull(NAME);
* // -> 'Joanna'
*
* myContainer.getOrNull(AGE);
* // -> null
* ```
*
* @remarks
* This class overrides certain properties of the container.
* Please be aware of this when using it.
*/
export class SnapshotHost {
/** Whether the container is currently in a snapshot. */
get isActive() {
return this.originalMetadataMap !== null;
}

/** The container the host is currently managing snapshots for. */
public readonly container: ContainerInstance;

/** The container's original {@link ContainerInstance.metadataMap | metadataMap} collection. */
private originalMetadataMap: null | Map<ServiceIdentifier, ServiceMetadata<unknown>> = null;

/** The container's original {@link ContainerInstance.multiServiceIds | multiServiceIds} collection. */
private originalMultiServiceIds: null | Map<ServiceIdentifier, ManyServicesMetadata> = null;

constructor(container: ContainerInstance) {
this.container = container;
}

/**
* Enter a snapshot period.
* If the host is already in a snapshot, this method does nothing.
*
* @example
* ```ts
* // Create a new container.
* const myContainer = Container.ofChild(Symbol());
*
* // Create a snapshot host to allow us to create and rollback changes.
* const snapshotHost = new SnapshotHost(myContainer);
*
* // Make some changes to the container outside of a snapshot.
* const NAME = new Token<string>();
* const AGE = new Token<number>();
* myContainer.set({ id: NAME, value: 'Joanna' });
*
* // Briefly enter a snapshot period...
* snapshotHost.beginSnapshot();
* myContainer.set({ id: NAME, value: 'Roxy' });
* myContainer.set({ id: AGE, value: 25 });
* snapshotHost.endSnapshot();
*
* myContainer.getOrNull(NAME);
* // -> 'Joanna'
*
* myContainer.getOrNull(AGE);
* // -> null
* ```
*/
beginSnapshot() {
if (this.isActive) {
return false;
}

/** Save references to the original properties so we can restore them later on. */
const { metadataMap: originalMetadataMap, multiServiceIds: originalMultiServiceIds } = this
.container as unknown as ContainerInternals;

this.originalMetadataMap = originalMetadataMap;
this.originalMultiServiceIds = originalMultiServiceIds;

const newMetadataMap = new ImmutableMap<ServiceIdentifier, ServiceMetadata<unknown>>(originalMetadataMap);
const newMultiServiceIds = new ImmutableMap<ServiceIdentifier, ManyServicesMetadata>(originalMultiServiceIds);

/** Override the container's map with a shadow map. @see {@link ImmutableMap}. */
Object.assign(this.container as unknown as ContainerInternals, {
metadataMap: newMetadataMap,
multiServiceIds: newMultiServiceIds,
});
}

endSnapshot() {
if (!this.isActive) {
return false;
}

/** Restore the container's original metadata stores. */
Object.assign(this.container as unknown as ContainerInternals, {
metadataMap: this.originalMetadataMap,
multiServiceIds: this.originalMultiServiceIds,
});

/** Setting "originalMetadataMap"" to null implicitly sets "isActive" to null. */
this.originalMetadataMap = null;
this.originalMultiServiceIds = null;

return true;
}
}
8 changes: 1 addition & 7 deletions src/contrib/transient-ref/transient-ref-host.class.mts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,7 @@ import { ResolutionConstraintFlag } from '../../types/resolution-constraint.type
import { resolveConstrainedContainer } from '../util/resolve-constrained-container.util.mjs';
import { ContainerScope } from '../../types/container-scope.type.mjs';
import { ServiceIdentifierLocation } from '../../types/service-identifier-location.type.mjs';

/** A helper to access private {@link ContainerInstance} methods. @ignore */
type ContainerInternals = {
metadataMap: ContainerInstance['metadataMap'];
resolveConstrainedIdentifier: ContainerInstance['resolveConstrainedIdentifier'];
resolveMultiID: ContainerInstance['resolveMultiID'];
};
import { ContainerInternals } from '../util/container-internals.util.mjs';

/**
* A helper object for managing instances of transient services.
Expand Down
9 changes: 9 additions & 0 deletions src/contrib/util/container-internals.util.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { ContainerInstance } from "../../container-instance.class.mjs";

/** A helper to access private {@link ContainerInstance} methods. @ignore */
export type ContainerInternals = {
metadataMap: ContainerInstance['metadataMap'];
resolveConstrainedIdentifier: ContainerInstance['resolveConstrainedIdentifier'];
resolveMultiID: ContainerInstance['resolveMultiID'];
multiServiceIds: ContainerInstance['multiServiceIds'];
};
3 changes: 3 additions & 0 deletions test/contrib/snapshot-host.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
describe('SnapshotHost', () => {

});