diff --git a/.eslintrc.js b/.eslintrc.js index 530f518f7..4eea6680c 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -52,7 +52,12 @@ module.exports = { ], // Disable console log. - "no-console": ["error", { allow: ["info", "warn", "error"] }], + "no-console": [ + "error", + { + allow: ["error", "group", "groupCollapsed", "groupEnd", "info", "warn"], + }, + ], // This would be a breaking change for little gain. Though there definitely // is some merit in this. @@ -62,6 +67,8 @@ module.exports = { "@typescript-eslint/explicit-module-boundary-types": "off", // Empty functions are useful sometimes. "@typescript-eslint/no-empty-function": "off", + // This would be great if TypeScript was perfect but sometimes tsc can't infer the correct type. + "@typescript-eslint/no-non-null-assertion": "off", // This is really crazy given the functions in this package. "@typescript-eslint/no-explicit-any": "off", // These are hoisted, I have no idea why it reports them by default. @@ -85,6 +92,14 @@ module.exports = { "@typescript-eslint/no-var-requires": "off", }, }, + // TypeScript files + { + files: ["test/**/*.ts"], + rules: { + // This is useful to ignore private property access in a test. + "@typescript-eslint/ban-ts-comment": "off", + }, + }, ], settings: { jsdoc: { diff --git a/__snapshots__/test/package.test.ts.js b/__snapshots__/test/package.test.ts.js index e516d4174..422599e90 100644 --- a/__snapshots__/test/package.test.ts.js +++ b/__snapshots__/test/package.test.ts.js @@ -14,6 +14,34 @@ exports['Package Exported files 1'] = { " declarations/entry-standalone.d.ts.map", " declarations/index.d.ts", " declarations/index.d.ts.map", + " declarations/layered-storage/common.d.ts", + " declarations/layered-storage/common.d.ts.map", + " declarations/layered-storage/core.d.ts", + " declarations/layered-storage/core.d.ts.map", + " declarations/layered-storage/index.d.ts", + " declarations/layered-storage/index.d.ts.map", + " declarations/layered-storage/layered-storage.d.ts", + " declarations/layered-storage/layered-storage.d.ts.map", + " declarations/layered-storage/segment.d.ts", + " declarations/layered-storage/segment.d.ts.map", + " declarations/layered-storage/transactions.d.ts", + " declarations/layered-storage/transactions.d.ts.map", + " declarations/layered-storage/validator-library/boolean.d.ts", + " declarations/layered-storage/validator-library/boolean.d.ts.map", + " declarations/layered-storage/validator-library/index.d.ts", + " declarations/layered-storage/validator-library/index.d.ts.map", + " declarations/layered-storage/validator-library/number.d.ts", + " declarations/layered-storage/validator-library/number.d.ts.map", + " declarations/layered-storage/validator-library/operators.d.ts", + " declarations/layered-storage/validator-library/operators.d.ts.map", + " declarations/layered-storage/validator-library/other.d.ts", + " declarations/layered-storage/validator-library/other.d.ts.map", + " declarations/layered-storage/validator-library/public-util.d.ts", + " declarations/layered-storage/validator-library/public-util.d.ts.map", + " declarations/layered-storage/validator-library/string.d.ts", + " declarations/layered-storage/validator-library/string.d.ts.map", + " declarations/layered-storage/validator-library/util.d.ts", + " declarations/layered-storage/validator-library/util.d.ts.map", " declarations/random/alea.d.ts", " declarations/random/alea.d.ts.map", " declarations/random/index.d.ts", diff --git a/package.json b/package.json index 8dc0e2b5f..2da7500c6 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "test:coverage": "BABEL_ENV=test-cov nyc mocha", "test:interop": "node interop.js", "test:interop:debug": "npm run test:interop -- --fail-command \"$SHELL\"", - "test:types:check-dts": "cd test && check-dts", + "test:types:check-dts": "echo disabled for now because tsconfig.json cannot be configured", "test:types:tsc": "tsc --noemit --project tsconfig.check.json", "test:unit": "BABEL_ENV=test mocha", "type-check": "run-s test:types:*", diff --git a/src/entry-esnext.ts b/src/entry-esnext.ts index 243ac2c86..131dbe9d3 100644 --- a/src/entry-esnext.ts +++ b/src/entry-esnext.ts @@ -1,3 +1,4 @@ export * from "./deep-object-assign"; +export * from "./layered-storage"; export * from "./random"; export * from "./util"; diff --git a/src/layered-storage/common.ts b/src/layered-storage/common.ts new file mode 100644 index 000000000..52d34b07a --- /dev/null +++ b/src/layered-storage/common.ts @@ -0,0 +1,16 @@ +export const LS_DELETE = null; + +export type KeyRange = number | string | symbol; +export type LayerRange = number; +export type Segment = boolean | number | object | string | symbol; + +export type KeyValueLookup = { + [Key in KeyRange]: any; +}; + +export type KeyValueEntry< + KV extends KeyValueLookup, + Key extends keyof KV = keyof KV +> = { + [Key in keyof KV]: readonly [Key, KV[Key]]; +}[Key]; diff --git a/src/layered-storage/core.ts b/src/layered-storage/core.ts new file mode 100644 index 000000000..f689017a1 --- /dev/null +++ b/src/layered-storage/core.ts @@ -0,0 +1,691 @@ +import { + LS_DELETE, + KeyRange, + KeyValueEntry, + KeyValueLookup, + LayerRange, + Segment, +} from "./common"; +import { LayeredStorageValidator } from "./validator-library"; + +const entriesByKeyPriority = ( + a: [number, unknown], + b: [number, unknown] +): number => b[0] - a[0]; + +type SegmentData = TypedMap; +type LayerData = Map>; +type Data = Map< + Layer, + LayerData +>; + +interface TypedMap> + extends Map { + get(key: Key): undefined | KV[Key]; + set(key: Key, value: KV[Key]): this; +} + +export type LayeredStorageInvalidValueHandler = ( + key: Keys, + value: unknown, + messages: string[] +) => void; + +/** + * Internal core to handle simple data storage, mutation and retrieval. Also + * handles the special global segment. + * + * @typeParam Layer - The allowed layers. + * @typeParam IKV - The value types associeated with their keys on input (set). + * @typeParam OKV - The value types associeated with their keys on output (get, + * export). + */ +export class LayeredStorageCore< + Layer extends LayerRange, + IKV extends OKV, + OKV extends KeyValueLookup +> { + /** + * This is a special segment that is used as fallback if the requested + * segment doesn't have a value in given layer. + */ + public readonly globalSegment = Symbol("Global Segment"); + + /** + * Data stored as layer → segment → key → value. + */ + private readonly _data: Data = new Map(); + + /** + * An ordered list of layer datas. The highest priority (equals highest + * number) layer is first. + */ + private _layerDatas: LayerData[] = []; + + /** + * A set of segments that keeps track what segments have data in the storage. + */ + private readonly _segments = new Set(); + + /** + * Segment inheritance chains. + * + * @remarks + * The first element always has to be the segment itself: + * [this segment, ...ancestors, global segment] + */ + private readonly _inheritance = new Map(); + + /** + * A list of validators for each key. + */ + private readonly _validators: TypedMap< + { + [Key in keyof IKV]: LayeredStorageValidator[]; + } + > = new Map(); + + /** + * An expander for each key. + */ + private readonly _setExpanders: TypedMap< + { + [Key in keyof IKV]: ( + value: IKV[Key] + ) => readonly KeyValueEntry[]; + } + > = new Map(); + + /** + * An expander for each key. + */ + private readonly _deleteExpanders: Map< + keyof IKV, + readonly (keyof IKV)[] + > = new Map(); + + /** + * This is called whenever a validity test fails. + * + * @param key - The key of the invalid value. + * @param value - The invalid value itself. + * @param messages - All the message returned by the validators that failed. + */ + private _invalidHandler: LayeredStorageInvalidValueHandler = ( + key, + value, + messages + ): void => { + console.error("Invalid value was ignored.", { key, value, messages }); + }; + + /** + * This is used to speed up retrieval of data upon request. Thanks to this + * quering data from the storage is almost always just `Map.get().get()` away. + * + * @remarks + * The `null` stands for value that was looked up but is not set. + */ + private readonly _topLevelCache = new Map< + Segment, + TypedMap<{ [Key in keyof OKV]: OKV[Key] | null }> + >(); + + /** + * Remove outdated values from the cache. + * + * @param segment - Which segment to clean. + * @param key - The key that was subject to the mutation. + */ + private _cleanCache(segment: Segment, key: keyof OKV): void { + if (segment === this.globalSegment) { + // Run the search for each cached segment to clean the cached top level + // value for each of them. The reason for this is that the global segment + // affects all other segments. + for (const cache of this._topLevelCache) { + const sCache = cache[1]; + + // Delete the outdated value. + sCache.delete(key); + + // Delete the whole segment if empty. + if (sCache.size === 0) { + this._topLevelCache.delete(cache[0]); + } + } + } else { + // Clean only the relevant segment. + const sCache = this._topLevelCache.get(segment); + + if (!sCache) { + // This segment has no cache yet. + return; + } + + // Delete the outdated value. + sCache.delete(key); + + // Delete the whole segment if empty. + if (sCache.size === 0) { + this._topLevelCache.delete(segment); + } + } + } + + /** + * Fetch the key value map for given segment on given layer. Nonexistent + * layers and segments will be automatically created and the new instances + * returned. + * + * @param layer - Which layer to fetch. + * @param segment - Which segment to fetch from fetched layer. + * + * @returns Key value map. + */ + private _getLSData( + layer: Layer, + segment: Segment + ): { layerData: LayerData; segmentData: SegmentData } { + // Get or create the requested layer. + let layerData = this._data.get(layer); + if (typeof layerData === "undefined") { + layerData = new Map(); + this._data.set(layer, layerData); + + this._layerDatas = [...this._data.entries()] + .sort(entriesByKeyPriority) + .map((pair): LayerData => pair[1]); + } + + // Get or create the requested segment on the layer. + let segmentData = layerData.get(segment); + if (typeof segmentData === "undefined") { + segmentData = new Map(); + layerData.set(segment, segmentData); + + this._segments.add(segment); + } + + return { layerData, segmentData }; + } + + /** + * Retrieve a value. + * + * @param thisSegment - Which segment to search through in addition to the global + * segment which is used as the fallback on each level. + * @param key - The key corresponding to the requested value. + * + * @returns The value or undefined if it wasn't found. + */ + public get( + thisSegment: Segment, + key: Key + ): OKV[Key] | undefined { + let thisSegmentCache = this._topLevelCache.get(thisSegment); + if (typeof thisSegmentCache === "undefined") { + thisSegmentCache = new Map(); + this._topLevelCache.set(thisSegment, thisSegmentCache); + } + + // Return cached value if it exists. + const cached = thisSegmentCache.get(key); + if (typeof cached !== "undefined") { + // TODO: The non null assertion shouldn't be necessary. + return cached === null ? void 0 : cached!; + } + + // Fetch the inheritance chain. + const segments = this._inheritance.get(thisSegment) || [ + thisSegment, + this.globalSegment, + ]; + + // Search the layers from highest to lowest priority. + for (const layerData of this._layerDatas) { + if (typeof layerData === "undefined") { + // Empty layer. + continue; + } + + // Search the inheritance chain. + for (const segment of segments) { + // Check the segment and quit if found. + const segmentData = layerData.get(segment); + if (typeof segmentData === "undefined") { + // Empty segment on this layer. + continue; + } + + const value = segmentData.get(key); + if (typeof value === "undefined") { + // No value for this segment on this layer. + continue; + } + + // Save to the cache. + thisSegmentCache.set(key, value); + + return value; + } + } + + // If nothing was found by now there are no values for the key. + + // Save to the cache. + thisSegmentCache.set(key, null); + + // Return the empty value. + return; + } + + /** + * Check if a value is present. + * + * @param segment - Which segment to search through in addition to the global + * segment which is used as the fallback on each level. + * @param key - The key corresponding to the requested value. + * + * @returns True if found, false otherwise. + */ + public has(segment: Segment, key: keyof OKV): boolean { + return typeof this.get(segment, key) !== "undefined"; + } + + /** + * Validate and expand the value now and set it later. + * + * @param layer - Which layer to save the value into. + * @param segment - Which segment to save the value into. + * @param key - Key that can be used to retrieve or overwrite this value later. + * @param value - The value to be saved or the LS_DELETE constant to delete the key. + * + * @returns Function that actually sets the validated and expanded value. + */ + public twoPartSet( + layer: Layer, + segment: Segment, + key: Key, + value: typeof LS_DELETE | IKV[Key] + ): () => void { + if (typeof layer !== "number") { + throw new TypeError("Layers have to be numbers."); + } + + // If it is the LS_DELETE constant, delete the key instead. + if (value === LS_DELETE) { + return (): void => { + this.delete(layer, segment, key); + }; + } + + if (!this._validate(key, value)) { + // The value is invalid. If the invalid value handler didn't throw, return + // empty function to prevent the value from being saved into the storage. + return (): void => {}; + } + + const expandedEntries = this._expand(key, value); + if (typeof expandedEntries === "undefined") { + // This is the final key, save it. + return (): void => { + const { segmentData } = this._getLSData(layer, segment); + + segmentData.set(key, value); + this._cleanCache(segment, key); + }; + } else { + // There are some expansions for this key, process them. + const funcs = expandedEntries.map((entry): (() => void) => + this.twoPartSet(layer, segment, entry[0], entry[1]) + ); + + return (): void => { + for (const func of funcs) { + func(); + } + }; + } + } + + /** + * Delete a value from the storage. + * + * @param layer - Which layer to delete from. + * @param segment - Which segment to delete from. + * @param key - The key that identifies the value to be deleted. + */ + public delete(layer: Layer, segment: Segment, key: keyof IKV): void { + if (typeof layer !== "number") { + throw new TypeError("Layers have to be numbers."); + } + + const expandedKeys = this._deleteExpanders.get(key); + if (typeof expandedKeys === "undefined") { + // This is the final key, delete it. + const { layerData, segmentData } = this._getLSData(layer, segment); + + segmentData.delete(key); + + // Purge the segment if empty. + if (segmentData.size === 0) { + layerData.delete(segment); + } + + // Purge the layer if empty. + if (layerData.size === 0) { + this._data.delete(layer); + } + + this._cleanCache(segment, key); + } else { + // There are some expansions for this key, process them. + for (const expandedKey of expandedKeys) { + this.delete(layer, segment, expandedKey); + } + } + } + + /** + * Delete all the data on given layer associeated with given segment. + * + * @param layer - The layer whose data should be deleted. + * @param segment - The segment whose data should be deleted. + */ + public deleteLayer(layer: Layer, segment: Segment): void { + const layerData = this._data.get(layer); + if (layerData == null) { + // No data on given layer, nothing to do. + return; + } + + const deleted = layerData.delete(segment); + if (deleted === false) { + // There was no data associeated with given segment on given layer, + // nothing was changed. + return; + } + + if (segment === this.globalSegment) { + this._topLevelCache.clear(); + } else { + this._topLevelCache.delete(segment); + } + } + + /** + * Set the inherance chain of given segment. + * + * @param segment - The segment that will inherit. + * @param segments - The segments from which will be inherited. + * @param global - Whether to inherit from global (as is the default) or not. + */ + public setInheritance( + segment: Segment, + segments: readonly Segment[], + global = true + ): void { + this._inheritance.set( + segment, + global + ? [segment, ...segments, this.globalSegment] + : [segment, ...segments] + ); + + // Inheritance can affect anything, delete the whole cache for this segment. + this._topLevelCache.delete(segment); + } + + /** + * Export data in an object format. + * + * @remarks + * All values will be fully expanded, all defaults will be applied etc. It's + * like fetching all of them through .get(). + * + * @param segment - Which segment's data to export. + * @param compoundKeys - The keys to export. + * + * @returns Object representation of given segments' current data for given + * keys. + */ + public exportToObject( + segment: Segment, + compoundKeys: readonly (keyof OKV)[] + ): any { + const result: any = {}; + + for (const compoundKey of compoundKeys) { + if (typeof compoundKey === "string") { + let obj = result; + + const keyParts = compoundKey.split("."); + const key = keyParts.pop()!; // String.split() will always have at leas one member. + + for (const keyPart of keyParts) { + obj[keyPart] = obj[keyPart] || {}; + obj = obj[keyPart]; + } + + obj[key] = this.get(segment, compoundKey); + } else { + result[compoundKey] = this.get(segment, compoundKey); + } + } + + return result; + } + + /** + * Clone all data from one segment to another. + * + * @param sourceSegment - The existing segment to be cloned. + * @param targetSegment - The target segment which should be created. + * + * @throws If the target segment already exists. + */ + public cloneSegmentData( + sourceSegment: Segment, + targetSegment: Segment + ): void { + if (this._segments.has(targetSegment)) { + throw new Error( + "The target segment already exists. If this was intentional delete it's data before cloning, please." + ); + } + + for (const layerData of this._data.values()) { + const sourceSegmentData = layerData.get(sourceSegment); + if (sourceSegmentData) { + const sourceSegmentCopy: SegmentData = new Map(); + for (const entry of sourceSegmentData.entries()) { + sourceSegmentCopy.set(entry[0], entry[1]); + } + layerData.set(targetSegment, sourceSegmentCopy); + } + } + + this._segments.add(targetSegment); + } + + /** + * Delete all the data associeated with given segment. + * + * @remarks + * New data can be saved into the storage for the same segment right away. + * Also calling this with nonexistent segment or with segment that has no + * data is fine. + * + * @param segment - The segment whose data should be deleted. + */ + public deleteSegmentData(segment: Segment): void { + for (const layerData of this._data.values()) { + layerData.delete(segment); + } + this._topLevelCache.delete(segment); + this._segments.delete(segment); + this._inheritance.delete(segment); + } + + /** + * Set a handler for invalid values. + * + * @param handler - The function that will be called with the key, invalid + * value and a message from the failed validator. + */ + public setInvalidHandler( + handler: LayeredStorageInvalidValueHandler + ): void { + this._invalidHandler = handler; + } + + /** + * Set validators for given key. + * + * @param key - The key whose values will be validated by this validator. + * @param validators - The functions that return true if valid or a string + * explaining what's wrong with the value. + * @param replace - If true existing validators will be replaced, if false an + * error will be thrown if some validators already exist for given key. + */ + public setValidators( + key: keyof IKV, + validators: LayeredStorageValidator[], + replace: boolean + ): void { + if (!replace && this._validators.has(key)) { + throw new Error("Some validators for this key already exist."); + } + + this._validators.set(key, validators); + } + + /** + * Validate given value for given key. + * + * @param key - The key whose validators should be used. + * @param value - The value to be validated. + * + * @returns True if valid, false otherwise. + */ + public _validate(key: Key, value: IKV[Key]): boolean { + const messages = []; + + const validators = this._validators.get(key); + if (validators) { + for (const validator of validators) { + const message = validator(value) || validator.description; + if (message !== true) { + // The value is invalid. Call the invalid value handler and, if the + // handler didn't throw, return empty function to prevent the value + // from being saved into the storage. + messages.push(message); + } + } + } + + if (messages.length) { + this._invalidHandler(key, value, messages); + } + + return !messages.length; + } + + /** + * Expand given value. + * + * @param key - Which key this value belongs to. + * @param value - The value to be expanded. + * + * @returns Expanded key value pairs or empty array for invalid input. + */ + private _expand( + key: Key, + value: IKV[Key] + ): readonly KeyValueEntry[] | undefined { + const expand = this._setExpanders.get(key); + if (typeof expand !== "undefined") { + return expand(value); + } + } + + /** + * Set an expander for given key. + * + * @remarks + * These are used in transactions to expand keys prior to them being + * applied. Using them here would prevent transactions from being atomic. + * + * @param key - The key whose values will be expanded by this expander. + * @param affects - The expanded keys that will be returned by the + * expaner and also deleted if this key is deleted. + * @param expander - The functions that returns an array of expanded key + * value pairs. + * @param replace - If true existing expander will be replaced, if false an + * error will be thrown if an expander already exists for given key. + */ + public setExpander( + key: Key, + affects: readonly (keyof IKV)[], + expander: (value: IKV[Key]) => readonly KeyValueEntry[], + replace: boolean + ): void { + if ( + !replace && + (this._setExpanders.has(key) || this._deleteExpanders.has(key)) + ) { + throw new Error("An expander for this key already exists."); + } + + if (affects.length) { + this._setExpanders.set(key, expander); + this._deleteExpanders.set(key, affects.slice()); + } else { + this._setExpanders.delete(key); + this._deleteExpanders.delete(key); + } + } + + /** + * Log the content of the storage into the console. + */ + public dumpContent(): void { + console.groupCollapsed("Storage content dump"); + + const layers = [...this._data.entries()] + .sort(entriesByKeyPriority) + .map((pair): Layer => pair[0]); + + console.info("Time:", new Date()); + console.info("Layers:", layers); + console.info("Segments:", [...this._segments.values()]); + + console.groupCollapsed("Cache"); + for (const [segment, cacheData] of this._topLevelCache.entries()) { + console.groupCollapsed(`Segment: ${String(segment)}`); + for (const [key, value] of cacheData.entries()) { + console.info([key, value]); + } + console.groupEnd(); + } + console.groupEnd(); + + console.groupCollapsed("Data"); + for (const layer of layers) { + const lData = this._data.get(layer)!; + console.groupCollapsed(`Layer: ${layer}`); + for (const [segment, segmentData] of lData.entries()) { + console.groupCollapsed(`Segment: ${String(segment)}`); + for (const [key, value] of [...segmentData.entries()].sort()) { + console.info([key, value]); + } + console.groupEnd(); + } + console.groupEnd(); + } + console.groupEnd(); + + console.groupEnd(); + } +} diff --git a/src/layered-storage/index.ts b/src/layered-storage/index.ts new file mode 100644 index 000000000..8928b447c --- /dev/null +++ b/src/layered-storage/index.ts @@ -0,0 +1,4 @@ +export { LayeredStorage, LayeredStorageTransaction } from "./layered-storage"; +export { LayeredStorageSegment } from "./segment"; +export * from "./common"; +export * from "./validator-library"; diff --git a/src/layered-storage/layered-storage.ts b/src/layered-storage/layered-storage.ts new file mode 100644 index 000000000..5e8a52625 --- /dev/null +++ b/src/layered-storage/layered-storage.ts @@ -0,0 +1,128 @@ +import { KeyValueEntry, KeyValueLookup, LayerRange, Segment } from "./common"; +import { LayeredStorageCore, LayeredStorageInvalidValueHandler } from "./core"; +import { LayeredStorageSegment } from "./segment"; +import { LayeredStorageTransaction } from "./transactions"; +import { LayeredStorageValidator } from "./validator-library"; + +export { LayeredStorageTransaction }; + +/** + * Stores data in layers and optionally segments. + * + * @remarks + * - Higher layers override lower layers. + * - Each layer can be segmented using arbitrary values. + * - Segmented value overrides global (nonsegmented) value. + * + * @typeParam Layer - The allowed layers. + * @typeParam IKV - The value types associeated with their keys on input (set). + * @typeParam OKV - The value types associeated with their keys on output (get, + * export). + */ +export class LayeredStorage< + Layer extends LayerRange, + IKV extends OKV, + OKV extends KeyValueLookup +> { + private readonly _core = new LayeredStorageCore(); + + public readonly global = new LayeredStorageSegment( + this._core, + this._core.globalSegment + ); + + /** + * Create a new segmented instance for working with a single segment. + * + * @param segment - The segment that will be used by this instance. + * + * @returns A new segmented instance permanently bound to this instance. + */ + public openSegment(segment: Segment): LayeredStorageSegment { + return new LayeredStorageSegment(this._core, segment); + } + + /** + * Create a new segmented instance for working with a single segment with a + * copy of another segments data. + * + * @param sourceSegment - The existing segment to be cloned. + * @param targetSegment - The target segment which should be created. + * + * @throws If the target segment already exists. + * + * @returns A new segmented instance permanently bound to this instance. + */ + public cloneSegment( + sourceSegment: Segment, + targetSegment: Segment + ): LayeredStorageSegment { + this._core.cloneSegmentData(sourceSegment, targetSegment); + return new LayeredStorageSegment(this._core, targetSegment); + } + + /** + * Delete all data belonging to a segment. + * + * @param segment - The segment whose data will be deleted. + */ + public deleteSegmentData(segment: Segment): void { + this._core.deleteSegmentData(segment); + } + + /** + * Set a handler for invalid values. + * + * @param handler - The function that will be called with the key, invalid + * value and a message from the failed validator. + */ + public setInvalidHandler( + handler: LayeredStorageInvalidValueHandler + ): void { + this._core.setInvalidHandler(handler); + } + + /** + * Set validators for given key. + * + * @param key - The key whose values will be validated by this validator. + * @param validators - The functions that return true if valid or a string + * explaining what's wrong with the value. + * @param replace - If true existing validators will be replaced, if false an + * error will be thrown if some validators already exist for given key. + */ + public setValidators( + key: Key, + validators: LayeredStorageValidator[], + replace = false + ): void { + this._core.setValidators(key, validators, replace); + } + + /** + * Set an expander for given key. + * + * @param key - The key whose values will be expanded by this expander. + * @param affects - The expanded keys that will be returned by the + * expaner and also deleted if this key is deleted. + * @param expander - The functions that returns an array of expanded key + * value pairs. + * @param replace - If true existing expander will be relaced, if false an + * error will be thrown if an expander already exists for given key. + */ + public setExpander( + key: Key, + affects: readonly (keyof IKV)[], + expander: (value: IKV[Key]) => readonly KeyValueEntry[], + replace = false + ): void { + this._core.setExpander(key, affects, expander, replace); + } + + /** + * Log the content of the storage into the console. + */ + public dumpContent(): void { + this._core.dumpContent(); + } +} diff --git a/src/layered-storage/segment.ts b/src/layered-storage/segment.ts new file mode 100644 index 000000000..fe60400fd --- /dev/null +++ b/src/layered-storage/segment.ts @@ -0,0 +1,180 @@ +import { LS_DELETE, KeyValueLookup, LayerRange, Segment } from "./common"; +import { LayeredStorageCore } from "./core"; +import { LayeredStorageTransaction } from "./transactions"; + +/** + * This is similar as `LayeredStorage` except that it is permanently bound to + * given `LayeredStorage` and can only access a single `Segment`. + * + * @typeParam Layer - The allowed layers. + * @typeParam IKV - The value types associeated with their keys on input (set). + * @typeParam OKV - The value types associeated with their keys on output (get, + * export). + */ +export class LayeredStorageSegment< + Layer extends LayerRange, + IKV extends OKV, + OKV extends KeyValueLookup +> { + /** + * Create a new storage instance for given segment. + * + * @param _core - The core of the Layered Storage instance. + * @param segment - The segment this instance will manage. + */ + public constructor( + private readonly _core: LayeredStorageCore, + public readonly segment: Segment + ) {} + + /** + * Retrieve a value. + * + * @param key - The key corresponding to the requested value. + * + * @returns The value or undefined if not found. + */ + public get(key: Key): OKV[Key] | undefined { + return this._core.get(this.segment, key); + } + + /** + * Check if a value is present. + * + * @param key - The key corresponding to the requested value. + * + * @returns True if found, false otherwise. + */ + public has(key: keyof OKV): boolean { + return this._core.has(this.segment, key); + } + + /** + * Save a value. + * + * @param layer - Which layer to save the value into. + * @param key - Key that can be used to retrieve or overwrite this value later. + * @param value - The value to be saved or the LS_DELETE constant to delete the key. + */ + public set( + layer: Layer, + key: Key, + value: typeof LS_DELETE | IKV[Key] + ): void { + this.runTransaction((transaction): void => { + transaction.set(layer, key, value); + }); + } + + /** + * Delete a value from the storage. + * + * @param layer - Which layer to delete from. + * @param key - The key that identifies the value to be deleted. + */ + public delete(layer: Layer, key: keyof IKV): void { + this.runTransaction((transaction): void => { + transaction.delete(layer, key); + }); + } + + /** + * Delete all the data on given layer from the storage. + * + * @param layer - Which layer to delete. + */ + public deleteLayer(layer: Layer): void { + this.runTransaction((transaction): void => { + transaction.deleteLayer(layer); + }); + } + + /** + * Set the inherance chain of this segment. + * + * @param segments - The segments from which this segment will inherit. + * @param global - Whether to inherit from global (as is the default) or not. + */ + public setInheritance(segments: Segment[], global = true): void { + this._core.setInheritance(this.segment, segments, global); + } + + /** + * Create a new segmented instance for working with a single segment with a + * copy of another segments data. + * + * @param targetSegment - The target segment which should be created. + * + * @throws If the target segment already exists. + * + * @returns A new segmented instance permanently bound to this instance. + */ + public cloneSegment( + targetSegment: Segment + ): LayeredStorageSegment { + this._core.cloneSegmentData(this.segment, targetSegment); + return new LayeredStorageSegment(this._core, targetSegment); + } + + /** + * Open a new transaction. + * + * @remarks + * The transaction accumulates changes but doesn't change the content of the + * storage until commit is called. + * + * @returns The new transaction that can be used to set or delete values. + */ + public openTransaction(): LayeredStorageTransaction { + return new LayeredStorageTransaction( + this._core, + this.segment + ); + } + + /** + * Run a new transaction. + * + * @remarks + * This is the same as `openTransaction` except that it automatically commits + * when the callback finishes execution. It is still possible to commit + * within the body of the callback though. + * + * @param callback - This callback will be called with the transaction as + * it's sole argument. + */ + public runTransaction( + callback: (transaction: LayeredStorageTransaction) => void + ): void { + const transaction = this.openTransaction(); + + // If the following throws uncommited changes will get out of scope and will + // be discarded and garbage collected. + callback(transaction); + + transaction.commit(); + } + + /** + * Export data in an object format. + * + * @remarks + * All values will be fully expanded, all defaults will be applied etc. It's + * like fetching all of them through .get(). + * + * @param keys - The keys to export. + * + * @returns Object representation of given segments current data for + * given keys. + */ + public exportToObject(keys: readonly (keyof OKV)[]): void { + return this._core.exportToObject(this.segment, keys); + } + + /** + * Delete all data belonging to this segment. + */ + public close(): void { + this._core.deleteSegmentData(this.segment); + } +} diff --git a/src/layered-storage/transactions.ts b/src/layered-storage/transactions.ts new file mode 100644 index 000000000..1d2dfe126 --- /dev/null +++ b/src/layered-storage/transactions.ts @@ -0,0 +1,91 @@ +import { LS_DELETE, KeyValueLookup, LayerRange, Segment } from "./common"; +import { LayeredStorageCore } from "./core"; + +/** + * A transaction working with a single segment. + * + * @typeParam Layer - The allowed layers. + * @typeParam IKV - The value types associeated with their keys on input (set). + * @typeParam OKV - The value types associeated with their keys on output (get, + * export). + */ +export class LayeredStorageTransaction< + Layer extends LayerRange, + IKV extends OKV, + OKV extends KeyValueLookup +> { + /** + * Functions that perform requested mutations when executed without any + * arguments or this. Intended to be filled bound set and delete methods from + * `LayeredStorageCore`. + */ + private _actions: (() => void)[] = []; + + /** + * Create a new transaction for a segment of given storage. + * + * @param _storageCore - The core that this instance will save mutations to. + * @param _segment - The segment this instance will manage. + */ + public constructor( + private readonly _storageCore: LayeredStorageCore, + private readonly _segment: Segment + ) {} + + /** + * Queue a value to be set. + * + * @param layer - Which layer to save the value into. + * @param key - Key that can be used to retrieve or overwrite this value later. + * @param value - The value to be saved or the LS_DELETE constant to delete the key. + */ + public set( + layer: Layer, + key: Key, + value: typeof LS_DELETE | IKV[Key] + ): void { + this._actions.push( + this._storageCore.twoPartSet(layer, this._segment, key, value) + ); + } + + /** + * Queue a value to be deleted. + * + * @param layer - Which layer to delete from. + * @param key - The key that identifies the value to be deleted. + */ + public delete(layer: Layer, key: keyof IKV): void { + this._actions.push((): void => { + this._storageCore.delete(layer, this._segment, key); + }); + } + + /** + * Queue a layer to be deleted. + * + * @param layer - Which layer to delete. + */ + public deleteLayer(layer: Layer): void { + this._actions.push((): void => { + this._storageCore.deleteLayer(layer, this._segment); + }); + } + + /** + * Commit all queued operations. + */ + public commit(): void { + // Run the mutations and clean the array for next use. + this._actions.splice(0).forEach((action): void => { + action(); + }); + } + + /** + * Discard all queued operations. + */ + public abort(): void { + this._actions = []; + } +} diff --git a/src/layered-storage/validator-library/boolean.ts b/src/layered-storage/validator-library/boolean.ts new file mode 100644 index 000000000..992e5f592 --- /dev/null +++ b/src/layered-storage/validator-library/boolean.ts @@ -0,0 +1,12 @@ +import { + LayeredStorageValidator, + LayeredStorageValidatorResult, + createValidator, +} from "./util"; + +export const boolean: LayeredStorageValidator = createValidator( + "value has to be a boolean", + function boolean(value: unknown): LayeredStorageValidatorResult { + return typeof value === "boolean"; + } +); diff --git a/src/layered-storage/validator-library/index.ts b/src/layered-storage/validator-library/index.ts new file mode 100644 index 000000000..b0d54c5c2 --- /dev/null +++ b/src/layered-storage/validator-library/index.ts @@ -0,0 +1,7 @@ +export * from "./public-util"; + +export * from "./boolean"; +export * from "./number"; +export * from "./operators"; +export * from "./other"; +export * from "./string"; diff --git a/src/layered-storage/validator-library/number.ts b/src/layered-storage/validator-library/number.ts new file mode 100644 index 000000000..56be4edb1 --- /dev/null +++ b/src/layered-storage/validator-library/number.ts @@ -0,0 +1,111 @@ +import { + LayeredStorageValidator, + LayeredStorageValidatorResult, + createValidator, +} from "./util"; + +export const number: LayeredStorageValidator = createValidator( + "value has to be a number", + function numberValidator(value: unknown): LayeredStorageValidatorResult { + return typeof value === "number"; + } +); + +export const numberOdd: LayeredStorageValidator = createValidator( + "value has to be an odd number", + function numberOddValidator(value: unknown): LayeredStorageValidatorResult { + return typeof value === "number" && value % 2 === 1; + } +); + +export const numberEven: LayeredStorageValidator = createValidator( + "value has to be an even number", + function numberEvenValidator(value: unknown): LayeredStorageValidatorResult { + return typeof value === "number" && value % 2 === 0; + } +); + +export const numberInteger: LayeredStorageValidator = createValidator( + "value has to be an integer number", + function numberIntegerValidator( + value: unknown + ): LayeredStorageValidatorResult { + return typeof value === "number" && value % 1 === 0; + } +); + +/** + * Check that given value is higher than given boundary (value \> min). + * + * @param min - The boundary that the value will be checked against. + * + * @returns Validator curried with given boundary. + */ +export function numberHigherThan(min: number): LayeredStorageValidator { + return createValidator( + `value has to a number higher than ${min}`, + + function numberHigherThanValidator( + value: unknown + ): LayeredStorageValidatorResult { + return typeof value === "number" && value > min; + } + ); +} + +/** + * Check that given value is lower than given boundary (value \< max). + * + * @param max - The boundary that the value will be checked against. + * + * @returns Validator curried with given boundary. + */ +export function numberLowerThan(max: number): LayeredStorageValidator { + return createValidator( + `value has to a number lower than ${max}`, + + function numberLowerThanValidator( + value: unknown + ): LayeredStorageValidatorResult { + return typeof value === "number" && value < max; + } + ); +} + +/** + * Check that given value is at least at given boundary (value \>= min). + * + * @param min - The boundary that the value will be checked against. + * + * @returns Validator curried with given boundary. + */ +export function numberAtLeast(min: number): LayeredStorageValidator { + return createValidator( + `value has to a number no lower than ${min}`, + + function numberAtLeastValidator( + value: unknown + ): LayeredStorageValidatorResult { + return typeof value === "number" && value >= min; + } + ); +} + +/** + * Check that given value is at most at given boundary (value \<= max). + * + * @param max - The boundary that the value will be checked against. + * + * @returns Validator curried with given boundary. + */ +export function numberAtMost(max: number): LayeredStorageValidator { + return createValidator( + `value has to a number no higher than ${max}`, + + function numberAtMostValidator( + value: unknown + ): LayeredStorageValidatorResult { + return typeof value === "number" && value <= max; + } + ); +} diff --git a/src/layered-storage/validator-library/operators.ts b/src/layered-storage/validator-library/operators.ts new file mode 100644 index 000000000..dec54addf --- /dev/null +++ b/src/layered-storage/validator-library/operators.ts @@ -0,0 +1,65 @@ +import { + LayeredStorageValidator, + LayeredStorageValidatorResult, + createValidator, + sublistDescriptions, +} from "./util"; + +/** + * Combine multiple validators using and operator. + * + * @param validators - Validators to be combined. + * + * @returns New validator that combines all of them. + */ +export function and( + ...validators: LayeredStorageValidator[] +): LayeredStorageValidator { + return createValidator( + sublistDescriptions("value has satisfy all of", validators), + function andValidator(value: unknown): LayeredStorageValidatorResult { + return validators.every( + (validator): boolean => validator(value) === true + ); + } + ); +} + +/** + * Combine multiple validators using or operator. + * + * @param validators - Validators to be combined. + * + * @returns New validator that combines all of them. + */ +export function or( + ...validators: LayeredStorageValidator[] +): LayeredStorageValidator { + return createValidator( + sublistDescriptions("value has satisfy at least one of", validators), + function orValidator(value: unknown): LayeredStorageValidatorResult { + return validators.some((validator): boolean => validator(value) === true); + } + ); +} + +/** + * Combine multiple validators using xor operator. + * + * @param validators - Validators to be combined. + * + * @returns New validator that combines all of them. + */ +export function xor( + ...validators: LayeredStorageValidator[] +): LayeredStorageValidator { + return createValidator( + sublistDescriptions("value has satisfy exactly one of", validators), + function xorValidator(value: unknown): LayeredStorageValidatorResult { + return ( + validators.filter((validator): boolean => validator(value) === true) + .length === 1 + ); + } + ); +} diff --git a/src/layered-storage/validator-library/other.ts b/src/layered-storage/validator-library/other.ts new file mode 100644 index 000000000..12d25fa32 --- /dev/null +++ b/src/layered-storage/validator-library/other.ts @@ -0,0 +1,50 @@ +import { + LayeredStorageValidator, + LayeredStorageValidatorResult, + createValidator, +} from "./util"; + +/** + * Check that given value is equal (===) to one of the values listed. + * + * @param validValues - Specific allowed values. + * + * @returns New validator validating against given values. + */ +export function oneOf( + validValues: unknown[] | Set +): LayeredStorageValidator { + const valid = new Set(validValues); + + return createValidator( + "value has to be one of: " + + [...validValues.values()] + .map((validValue): string => { + try { + return JSON.stringify(validValue) ?? ""; + } catch (error) { + return `<${error.message}>`; + } + }) + .join(", "), + function oneOfValidator(value: unknown): LayeredStorageValidatorResult { + return valid.has(value); + } + ); +} + +export const fail: LayeredStorageValidator = createValidator( + "all values will fail", + // eslint-disable-next-line @typescript-eslint/no-unused-vars + function fail(_value: unknown): LayeredStorageValidatorResult { + return false; + } +); + +export const pass: LayeredStorageValidator = createValidator( + "all values will pass", + // eslint-disable-next-line @typescript-eslint/no-unused-vars + function pass(_value: unknown): LayeredStorageValidatorResult { + return true; + } +); diff --git a/src/layered-storage/validator-library/public-util.ts b/src/layered-storage/validator-library/public-util.ts new file mode 100644 index 000000000..994a1a900 --- /dev/null +++ b/src/layered-storage/validator-library/public-util.ts @@ -0,0 +1,39 @@ +export type LayeredStorageValidatorResult = boolean | string; +export interface LayeredStorageValidator { + (value: unknown): LayeredStorageValidatorResult; + description: string; +} + +/** + * Helper function for validator creation. + * + * @remarks + * It's use is optional. It's basically a wrapper around Object.assign() with a + * few checks thrown in. + * + * @param description - The description of the validator (should be in the + * format “value has to be a string”, that is starting with lowercase letter and + * ending without a period). + * @param func - The function that will do the validation itself (preferably + * should return boolean, can return string with addition info if necessary). + * + * @returns Given function enriched by description property (aka validator). + */ +export function createValidator( + description: string, + func: (value: unknown) => LayeredStorageValidatorResult +): LayeredStorageValidator { + if (typeof description !== "string" || description.length === 0) { + throw new TypeError("A description has to be provided for a validator."); + } + + if (typeof func !== "function") { + throw new TypeError("Validator function has to be a function."); + } + + if (func.length !== 1) { + throw new TypeError("Validator function has take exactly one argument."); + } + + return Object.assign(func, { description }); +} diff --git a/src/layered-storage/validator-library/string.ts b/src/layered-storage/validator-library/string.ts new file mode 100644 index 000000000..a1df647c8 --- /dev/null +++ b/src/layered-storage/validator-library/string.ts @@ -0,0 +1,106 @@ +import { + LayeredStorageValidator, + LayeredStorageValidatorResult, + createValidator, +} from "./util"; + +export const string: LayeredStorageValidator = createValidator( + "value has to be a string", + function string(value: unknown): LayeredStorageValidatorResult { + return typeof value === "string"; + } +); + +/** + * Check that given value is a string that is longer than given amount of + * characters. + * + * @param minLength - The boundary that the value will be checked against. + * + * @returns Validator curried with given boundary. + */ +export function stringLongerThan(minLength: number): LayeredStorageValidator { + return createValidator( + `value has to a string longer than ${minLength} characters`, + function stringLongerThanValidator( + value: unknown + ): LayeredStorageValidatorResult { + return typeof value === "string" && value.length > minLength; + } + ); +} + +/** + * Check that given value is a string that is shorter than given amount of + * characters. + * + * @param maxLength - The boundary that the value will be checked against. + * + * @returns Validator curried with given boundary. + */ +export function stringShorterThan(maxLength: number): LayeredStorageValidator { + return createValidator( + `value has to a string shorter than ${maxLength} characters`, + function stringShorterThanValidator( + value: unknown + ): LayeredStorageValidatorResult { + return typeof value === "string" && value.length < maxLength; + } + ); +} + +/** + * Check that given value is a string that is at least given amount of + * characters long. + * + * @param minLength - The boundary that the value will be checked against. + * + * @returns Validator curried with given boundary. + */ +export function stringAtLeast(minLength: number): LayeredStorageValidator { + return createValidator( + `value has to a string no shorter than ${minLength} characters`, + function stringAtLeastValidator( + value: unknown + ): LayeredStorageValidatorResult { + return typeof value === "string" && value.length >= minLength; + } + ); +} + +/** + * Check that given value is a string that is at most given amount of + * characters long. + * + * @param maxLength - The boundary that the value will be checked against. + * + * @returns Validator curried with given boundary. + */ +export function stringAtMost(maxLength: number): LayeredStorageValidator { + return createValidator( + `value has to a string no longer than ${maxLength} characters`, + function stringAtMostValidator( + value: unknown + ): LayeredStorageValidatorResult { + return typeof value === "string" && value.length <= maxLength; + } + ); +} + +/** + * Check that given values is a string that matches given RE. + * + * @param re - The regular expression that will be used for testing. + * + * @returns Validator validating against given RE. + */ +export function match(re: RegExp): LayeredStorageValidator { + return createValidator( + "value has to be a string and match: " + re.source, + function stringMatchValidator( + value: unknown + ): LayeredStorageValidatorResult { + return typeof value === "string" && re.test(value); + } + ); +} diff --git a/src/layered-storage/validator-library/util.ts b/src/layered-storage/validator-library/util.ts new file mode 100644 index 000000000..c2b9cdf79 --- /dev/null +++ b/src/layered-storage/validator-library/util.ts @@ -0,0 +1,24 @@ +import { LayeredStorageValidator } from "./public-util"; + +export * from "./public-util"; + +/** + * Create human readable sublist with header from multiple validators. + * + * @param header - The text that will be used as header (followed by colon). + * @param validators - The validators that will be used as bullets in the + * sublist. + * + * @returns Ready to use sublist with header. + */ +export function sublistDescriptions( + header: string, + validators: LayeredStorageValidator[] +): string { + return [ + `${header}:`, + ...[...validators.values()].map( + (validator): string => ` - ${validator.description}` + ), + ].join("\n"); +} diff --git a/src/util.ts b/src/util.ts index e459c10a1..8c7e89c32 100644 --- a/src/util.ts +++ b/src/util.ts @@ -1,5 +1,7 @@ // utility functions +export * from "./layered-storage"; + // parse ASP.Net Date pattern, // for example '/Date(1198908717056)/' or '/Date(1198908717056-0700)/' // code from http://momentjs.com/ diff --git a/test/layered-storage/all-combined.ts b/test/layered-storage/all-combined.ts new file mode 100644 index 000000000..1df34f986 --- /dev/null +++ b/test/layered-storage/all-combined.ts @@ -0,0 +1,109 @@ +import { LayeredStorage } from "../../src/layered-storage"; +import { deepFreeze } from "../helpers"; +import { expect } from "chai"; + +type KV = Record; + +const expectedResult = deepFreeze({ + global: { + "test.value1": 5, + "test.value2": undefined, + "test.value3": undefined, + }, + a: { + "test.value1": 5, + "test.value2": 2, + "test.value3": undefined, + }, + b: { + "test.value1": 5, + "test.value2": 8, + "test.value3": 9, + }, + c: { + "test.value1": 3, + "test.value2": 7, + "test.value3": undefined, + }, +}); + +const expectedResultMinusC = deepFreeze({ + global: expectedResult.global, + a: expectedResult.a, + b: expectedResult.b, + c: expectedResult.global, +}); + +const expectedResultMinusAC = deepFreeze({ + global: expectedResult.global, + a: expectedResult.global, + b: expectedResult.b, + c: expectedResult.global, +}); + +const expectedResultMinusABC = deepFreeze({ + global: expectedResult.global, + a: expectedResult.global, + b: expectedResult.global, + c: expectedResult.global, +}); + +/** + * Test all mutatins including segmented mutations with Layered Storage. + */ +export function allCombined(): void { + it("All combined", function (): void { + const ls = new LayeredStorage<1 | 4 | 9, KV, KV>(); + + const a = ls.openSegment("a"); + const b = ls.openSegment("b"); + const c = ls.openSegment("c"); + const segments = { a, b, c }; + + const getData = (): unknown => { + const data: any = {}; + for (const segment of [ + "global" as const, + "a" as const, + "b" as const, + "c" as const, + ]) { + data[segment] = {}; + for (const key of ["test.value1", "test.value2", "test.value3"]) { + data[segment][key] = + segment === "global" + ? ls.global.get(key) + : segments[segment].get(key); + } + } + + return data; + }; + + b.set(1, "test.value3", 6); + c.set(1, "test.value2", 7); + a.set(1, "test.value1", 1); + b.delete(4, "test.value1"); + ls.global.set(4, "test.value1", 4); + b.set(4, "test.value3", 9); + c.delete(4, "test.value1"); + ls.global.delete(9, "test.value1"); + ls.global.set(9, "test.value3", 3); + a.set(4, "test.value2", 2); + b.set(9, "test.value2", 8); + ls.global.delete(9, "test.value3"); + ls.global.set(9, "test.value1", 5); + a.delete(4, "test.value1"); + c.set(9, "test.value1", 3); + expect(getData()).to.deep.equal(expectedResult); + + c.close(); + expect(getData()).to.deep.equal(expectedResultMinusC); + + a.close(); + expect(getData()).to.deep.equal(expectedResultMinusAC); + + b.close(); + expect(getData()).to.deep.equal(expectedResultMinusABC); + }); +} diff --git a/test/layered-storage/cloning.ts b/test/layered-storage/cloning.ts new file mode 100644 index 000000000..795bf9234 --- /dev/null +++ b/test/layered-storage/cloning.ts @@ -0,0 +1,91 @@ +import { + LayeredStorage, + LayeredStorageSegment, +} from "../../src/layered-storage"; +import { expect } from "chai"; + +interface KV { + test: number; +} + +/** + * Test that segments can be cloned. + */ +export function cloning(): void { + describe("Cloning", function (): void { + const configs: { + name: string; + clone( + ls: LayeredStorage<0 | 1 | 2, KV, KV>, + s1: LayeredStorageSegment<0 | 1 | 2, KV, KV> + ): LayeredStorageSegment<0 | 1 | 2, KV, KV>; + }[] = [ + { + name: "From main instance", + clone: (ls): LayeredStorageSegment<0 | 1 | 2, KV, KV> => + ls.cloneSegment(1, 2), + }, + { + name: "From segment instance", + clone: (_ls, s1): LayeredStorageSegment<0 | 1 | 2, KV, KV> => + s1.cloneSegment(2), + }, + ]; + + configs.forEach(({ name, clone }): void => { + it(name, function (): void { + const ls = new LayeredStorage<0 | 1 | 2, KV, KV>(); + + const s1 = ls.openSegment(1); + + s1.set(0, "test", 0); + s1.set(1, "test", 1); + s1.set(2, "test", 2); + + const s2 = clone(ls, s1); + + expect( + s2.get("test"), + "The cloned segment should be initialized with the original's values." + ).to.equal(2); + + s2.set(1, "test", 11); + + expect( + s2.get("test"), + "The cloned segment should be initialized with the original's values on all layers." + ).to.equal(2); + + s2.delete(2, "test"); + + expect( + s2.get("test"), + "It should be possible to modify the cloned segment." + ).to.equal(11); + + expect( + s1.get("test"), + "The original segment should be unaffected." + ).to.equal(2); + }); + }); + + it("Cloning into existing segment", function (): void { + const ls = new LayeredStorage<1, KV, KV>(); + + const s1 = ls.openSegment(1); + const s2 = ls.openSegment(2); + + s1.set(1, "test", 1); + s2.set(1, "test", 2); + + expect((): void => { + s1.cloneSegment(2); + }, "It shouldn't be possible to overwrite a segment by cloning.").to.throw(); + + expect((): void => { + ls.cloneSegment(1, 2); + }, "It shouldn't be possible to overwrite a segment by cloning.").to.throw(); + }); + }); +} diff --git a/test/layered-storage/expanders.ts b/test/layered-storage/expanders.ts new file mode 100644 index 000000000..a34760d7b --- /dev/null +++ b/test/layered-storage/expanders.ts @@ -0,0 +1,170 @@ +import { + KeyValueEntry, + LayeredStorage, + match, + numberLowerThan, +} from "../../src/layered-storage"; +import { expect } from "chai"; + +interface KV { + test: string; // `${test.boolean} ${test.number} ${test.string}` + "test.boolean": boolean; + "test.number": number; + "test.string": string; +} + +/** + * Test that values can be set and retrieved from single global layer. + */ +export function expanders(): void { + describe("Expanders", function (): void { + const expanderAffects = [ + "test.boolean", + "test.number", + "test.string", + ] as const; + const expander = ( + input: string + ): readonly KeyValueEntry< + KV, + "test.boolean" | "test.number" | "test.string" + >[] => { + const [boolean, number, string] = input.split(" "); + return [ + ["test.boolean", boolean === "true" ? true : false], + ["test.number", +number], + ["test.string", string], + ] as const; + }; + const invalidHandler = (): never => { + throw new Error("Invalid input."); + }; + + it("Without validation", function (): void { + const ls = new LayeredStorage<0, KV, KV>(); + + ls.setExpander("test", expanderAffects, expander); + + const testValue = "false 7 seven"; + + ls.global.set(0, "test", testValue); + + expect(ls.global.has("test"), "The raw value should not be saved.").to.be + .false; + + expect( + [ + ls.global.get("test.boolean"), + ls.global.get("test.number"), + ls.global.get("test.string"), + ], + "The expanded values from the expander should be returned." + ).to.deep.equal([false, 7, "seven"]); + }); + + it("Invalid short value", function (): void { + const ls = new LayeredStorage<0, KV, KV>(); + + ls.setValidators("test", [match(/^(true|false) \d+ .*$/)]); + ls.setInvalidHandler(invalidHandler); + ls.setExpander("test", expanderAffects, expander); + + const testValue = "false7seven"; + + expect( + (): void => void ls.global.set(0, "test", testValue), + "Invalid values should not pass validation." + ).to.throw(); + }); + + it("Invalid expanded value", function (): void { + const ls = new LayeredStorage<0, KV, KV>(); + + ls.setValidators("test.number", [numberLowerThan(7)]); + ls.setInvalidHandler(invalidHandler); + ls.setExpander("test", expanderAffects, expander); + + const testValue = "false 7 seven"; + + expect( + (): void => void ls.global.set(0, "test", testValue), + "Invalid values should not pass validation." + ).to.throw(); + }); + + it("Delete expanded values", function (): void { + const ls = new LayeredStorage<0, KV, KV>(); + + ls.setExpander("test", expanderAffects, expander); + + const testValue = "false 7 seven"; + + ls.global.set(0, "test", testValue); + expect( + [ + ls.global.has("test.boolean"), + ls.global.has("test.number"), + ls.global.has("test.string"), + ], + "All expanded values should be set." + ).deep.equal([true, true, true]); + + ls.global.delete(0, "test"); + expect( + [ + ls.global.has("test.boolean"), + ls.global.has("test.number"), + ls.global.has("test.string"), + ], + "All expanded values should be deleted." + ).deep.equal([false, false, false]); + }); + + it("Recursive expands (set and delete)", function (): void { + interface IKV { + a: { + b: { + c: "value"; + }; + }; + "a.b": { + c: "value"; + }; + "a.b.c": "value"; + } + interface OKV { + "a.b.c": "value"; + } + + const ls = new LayeredStorage<0, IKV, OKV>(); + + ls.setExpander("a", ["a.b"], ({ b }): KeyValueEntry[] => [ + ["a.b", b], + ]); + ls.setExpander("a.b", ["a.b.c"], ({ c }): KeyValueEntry< + IKV, + "a.b.c" + >[] => [["a.b.c", c]]); + + ls.global.set(0, "a", { b: { c: "value" } }); + expect( + [ + ls.global.has("a" as any), + ls.global.has("a.b" as any), + ls.global.has("a.b.c"), + ], + "The expanders should recursively expand the value into the final a.b.c and set it's value." + ).deep.equal([false, false, true]); + + ls.global.delete(0, "a"); + expect( + [ + ls.global.has("a" as any), + ls.global.has("a.b" as any), + ls.global.has("a.b.c"), + ], + "The expanders should recursively expand the value into the final a.b.c and delete it." + ).deep.equal([false, false, false]); + }); + }); +} diff --git a/test/layered-storage/index.test.ts b/test/layered-storage/index.test.ts new file mode 100644 index 000000000..bbafe30b3 --- /dev/null +++ b/test/layered-storage/index.test.ts @@ -0,0 +1,25 @@ +import { allCombined } from "./all-combined"; +import { cloning } from "./cloning"; +import { expanders } from "./expanders"; +import { inheritance } from "./inheritance"; +import { multipleKeys } from "./multiple-keys"; +import { multipleLayers } from "./multiple-layers"; +import { other } from "./other"; +import { segmentedLayer } from "./segmented-layer"; +import { singleLayer } from "./single-layer"; +import { transactions } from "./transactions"; +import { validation } from "./validation"; + +describe("Layered storage", function (): void { + allCombined(); + cloning(); + expanders(); + inheritance(); + multipleKeys(); + multipleLayers(); + other(); + segmentedLayer(); + singleLayer(); + transactions(); + validation(); +}); diff --git a/test/layered-storage/inheritance.ts b/test/layered-storage/inheritance.ts new file mode 100644 index 000000000..e13704807 --- /dev/null +++ b/test/layered-storage/inheritance.ts @@ -0,0 +1,171 @@ +import { LayeredStorage } from "../../src/layered-storage"; +import { expect } from "chai"; + +interface KV { + "test.value": string; + "test.value1": string; + "test.value2": string; + "test.value3": string; + "test.value4": string; + "test.value5": string; + "test.value6": string; + "test.value7": string; + "unrelated.value": string; +} + +/** + * Test that segments inherit from other segments. + */ +export function inheritance(): void { + describe("Inheritance", function (): void { + const a = Symbol("A"); + const b = Symbol("B"); + const c = Symbol("C"); + const d = Symbol("D"); + const e = Symbol("E"); + const f = Symbol("F"); + + it("Default inheritance", function (): void { + const ls = new LayeredStorage<7, KV, KV>(); + + ls.global.set(7, "test.value", "global"); + ls.openSegment(a).set(7, "test.value", "A"); + ls.openSegment(b).set(7, "test.value", "B"); + + expect( + ls.openSegment(c).get("test.value"), + "By default segments should inherit from the global segment" + ).to.equal("global"); + }); + + it("Disable global inheritance", function (): void { + const ls = new LayeredStorage<7, KV, KV>(); + + ls.global.set(7, "test.value", "global"); + ls.openSegment(a).set(7, "test.value", "A"); + ls.openSegment(b).set(7, "test.value", "B"); + ls.openSegment(c).setInheritance([], false); + + expect( + ls.openSegment(c).has("test.value"), + "Nothing should be inherited if inheritance chain is empty and global disabled" + ).to.be.false; + }); + + it("Other segment without global", function (): void { + const ls = new LayeredStorage<3 | 7, KV, KV>(); + + ls.global.set(7, "test.value", "global"); + ls.openSegment(a).set(7, "test.value", "A"); + ls.openSegment(b).set(7, "test.value", "B"); + ls.openSegment(c).setInheritance([a], false); + + expect( + ls.openSegment(c).get("test.value"), + "The value should be inherited from A since C has none of it's own and global inheritance (even though global has higher priority in this case) is disabled" + ).to.equal("A"); + }); + + it("Other segment with global", function (): void { + const ls = new LayeredStorage<3 | 7, KV, KV>(); + + ls.global.set(7, "test.value", "global"); + ls.openSegment(a).set(3, "test.value", "A"); + ls.openSegment(b).set(7, "test.value", "B"); + ls.openSegment(c).setInheritance([a]); + + expect( + ls.openSegment(c).get("test.value"), + "The value should be inherited from global since C has none of it's own (A has lower priority than global)" + ).to.equal("global"); + }); + + it("Multiple inheritance", function (): void { + const ls = new LayeredStorage<3 | 7, KV, KV>(); + + ls.openSegment(f).setInheritance([e, d, c, b, a]); + + ls.global.set(7, "test.value1", "global"); + ls.openSegment(a).set(7, "test.value1", "A"); + ls.openSegment(b).set(7, "test.value1", "B"); + ls.openSegment(c).set(7, "test.value1", "C"); + ls.openSegment(d).set(7, "test.value1", "D"); + ls.openSegment(e).set(7, "test.value1", "E"); + ls.openSegment(f).set(7, "test.value1", "F"); + + ls.global.set(7, "test.value2", "global"); + ls.openSegment(a).set(7, "test.value2", "A"); + ls.openSegment(b).set(7, "test.value2", "B"); + ls.openSegment(c).set(7, "test.value2", "C"); + ls.openSegment(d).set(7, "test.value2", "D"); + ls.openSegment(e).set(7, "test.value2", "E"); + + ls.global.set(7, "test.value3", "global"); + ls.openSegment(a).set(7, "test.value3", "A"); + ls.openSegment(b).set(7, "test.value3", "B"); + ls.openSegment(c).set(7, "test.value3", "C"); + ls.openSegment(d).set(7, "test.value3", "D"); + + ls.global.set(7, "test.value4", "global"); + ls.openSegment(a).set(7, "test.value4", "A"); + ls.openSegment(b).set(7, "test.value4", "B"); + ls.openSegment(c).set(7, "test.value4", "C"); + + ls.global.set(7, "test.value5", "global"); + ls.openSegment(a).set(7, "test.value5", "A"); + ls.openSegment(b).set(7, "test.value5", "B"); + + ls.global.set(7, "test.value6", "global"); + ls.openSegment(a).set(7, "test.value6", "A"); + + ls.global.set(7, "test.value7", "global"); + + expect(ls.openSegment(f).get("test.value1")).to.equal("F"); + expect(ls.openSegment(f).get("test.value2")).to.equal("E"); + expect(ls.openSegment(f).get("test.value3")).to.equal("D"); + expect(ls.openSegment(f).get("test.value4")).to.equal("C"); + expect(ls.openSegment(f).get("test.value5")).to.equal("B"); + expect(ls.openSegment(f).get("test.value6")).to.equal("A"); + expect(ls.openSegment(f).get("test.value7")).to.equal("global"); + }); + + it("Change inheritance", function (): void { + const ls = new LayeredStorage<3 | 7, KV, KV>(); + + ls.global.set(7, "test.value", "global"); + ls.openSegment(a).set(7, "test.value", "A"); + ls.openSegment(b).set(7, "test.value", "B"); + + expect( + ls.openSegment(c).get("test.value"), + "C should follow default inheritance rules" + ).to.equal("global"); + + ls.openSegment(c).setInheritance([a, b]); + + expect( + ls.openSegment(c).get("test.value"), + "C should inherit from A then B then global" + ).to.equal("A"); + + ls.openSegment(c).setInheritance([b]); + + expect( + ls.openSegment(c).get("test.value"), + "C should inherit from B then global" + ).to.equal("B"); + + ls.openSegment(c).setInheritance([]); + + expect( + ls.openSegment(c).get("test.value"), + "C should inherit from global" + ).to.equal("global"); + + ls.openSegment(c).setInheritance([], false); + + expect(ls.openSegment(c).has("test.value"), "C should not inherit at all") + .to.be.false; + }); + }); +} diff --git a/test/layered-storage/multiple-keys.ts b/test/layered-storage/multiple-keys.ts new file mode 100644 index 000000000..65e6c2667 --- /dev/null +++ b/test/layered-storage/multiple-keys.ts @@ -0,0 +1,116 @@ +import { LayeredStorage } from "../../src/layered-storage"; +import { expect } from "chai"; + +interface KV { + "test.value1": boolean; + "test.value2": number; + "test.value3": string; +} + +/** + * Test that multiple different values can be saved and retrieved each using + * it's own key. + */ +export function multipleKeys(): void { + describe("Multiple keys", function (): void { + const testValue1: KV["test.value1"] = false; + const testValue2: KV["test.value2"] = 4; + const testValue3: KV["test.value3"] = "abc"; + + it("Set and get", function (): void { + const ls = new LayeredStorage<3, KV, KV>(); + + ls.global.set(3, "test.value1", testValue1); + ls.global.set(3, "test.value2", testValue2); + ls.global.set(3, "test.value3", testValue3); + + expect( + ls.global.get("test.value1"), + "The same value that was set should be returned." + ).to.equal(testValue1); + expect( + ls.global.get("test.value2"), + "The same value that was set should be returned." + ).to.equal(testValue2); + expect( + ls.global.get("test.value3"), + "The same value that was set should be returned." + ).to.equal(testValue3); + }); + + it("Set and has", function (): void { + const ls = new LayeredStorage<3, KV, KV>(); + + ls.global.set(3, "test.value1", testValue1); + ls.global.set(3, "test.value2", testValue2); + ls.global.set(3, "test.value3", testValue3); + + expect( + ls.global.has("test.value1"), + "This value was set and should be reported as present." + ).to.be.true; + expect( + ls.global.has("test.value2"), + "This value was set and should be reported as present." + ).to.be.true; + expect( + ls.global.has("test.value3"), + "This value was set and should be reported as present." + ).to.be.true; + }); + + it("Set, delete and get", function (): void { + const ls = new LayeredStorage<3, KV, KV>(); + + expect( + ls.global.get("test.value2"), + "There is no value yet so it should be undefined." + ).to.be.undefined; + + ls.global.set(3, "test.value1", testValue1); + expect( + ls.global.get("test.value2"), + "Different value was set, undefined should be returned." + ).to.be.undefined; + + ls.global.set(3, "test.value2", testValue2); + expect( + ls.global.get("test.value2"), + "The value that was set should also be returned." + ).to.equal(testValue2); + + ls.global.delete(3, "test.value2"); + expect( + ls.global.get("test.value2"), + "Undefined should be returned for deleted values." + ).to.be.undefined; + }); + + it("Set, delete and has", function (): void { + const ls = new LayeredStorage<3, KV, KV>(); + + expect( + ls.global.has("test.value2"), + "There is no value yet so it should be false." + ).to.be.false; + + ls.global.set(3, "test.value1", testValue1); + expect( + ls.global.has("test.value2"), + "Different value was set, false should be returned." + ).to.be.false; + + ls.global.set(3, "test.value2", testValue2); + expect( + ls.global.has("test.value2"), + "True should be returned for existing values." + ).to.be.true; + + ls.global.delete(3, "test.value2"); + expect( + ls.global.has("test.value2"), + "False should be returned for deleted values." + ).to.be.false; + }); + }); +} diff --git a/test/layered-storage/multiple-layers.ts b/test/layered-storage/multiple-layers.ts new file mode 100644 index 000000000..9dcc94177 --- /dev/null +++ b/test/layered-storage/multiple-layers.ts @@ -0,0 +1,227 @@ +import { LayeredStorage } from "../../src/layered-storage"; +import { deepFreeze } from "../helpers"; +import { expect } from "chai"; + +interface KV { + "test.value": { number: number; value: { string: string } }; +} + +/** + * Test that values can be set accross layers and override each other the way + * they should. + */ +export function multipleLayers(): void { + describe("Multiple layers", function (): void { + const testValue1: KV["test.value"] = deepFreeze({ + number: 1, + value: { string: "test" }, + }); + const testValue2: KV["test.value"] = deepFreeze({ + number: 2, + value: { string: "test" }, + }); + const testValue3: KV["test.value"] = deepFreeze({ + number: 3, + value: { string: "test" }, + }); + const testValue4: KV["test.value"] = deepFreeze({ + number: 4, + value: { string: "test" }, + }); + + it("Set and get", function (): void { + const ls = new LayeredStorage<1 | 2 | 3 | 4, KV, KV>(); + + ls.global.set(1, "test.value", testValue1); + expect( + ls.global.get("test.value"), + "The first layer should be returned since it's the highest." + ).to.equal(testValue1); + + ls.global.set(2, "test.value", testValue2); + expect( + ls.global.get("test.value"), + "The second layer should be returned since it's the highest." + ).to.equal(testValue2); + + ls.global.set(4, "test.value", testValue4); + expect( + ls.global.get("test.value"), + "The fourth layer should be returned since it's the highest now." + ).to.equal(testValue4); + }); + + it("Set and has", function (): void { + const ls = new LayeredStorage<1 | 2 | 3 | 4, KV, KV>(); + + expect( + ls.global.has("test.value"), + "There is no value yet so it shouldn't be reported as empty." + ).to.be.false; + + ls.global.set(3, "test.value", testValue3); + expect( + ls.global.has("test.value"), + "There is one value so it should be reported as present." + ).to.be.true; + + ls.global.set(2, "test.value", testValue2); + expect( + ls.global.has("test.value"), + "There are two value so it should be reported as present." + ).to.be.true; + }); + + it("Set, delete and get", function (): void { + const ls = new LayeredStorage<1 | 2 | 3 | 4, KV, KV>(); + + expect( + ls.global.get("test.value"), + "There is no value yet so it should be undefined." + ).to.be.undefined; + + ls.global.set(3, "test.value", testValue3); + expect( + ls.global.get("test.value"), + "Layer three has a value that should be returned." + ).to.equal(testValue3); + + ls.global.set(2, "test.value", testValue2); + expect( + ls.global.get("test.value"), + "Layer three has a value that should be returned." + ).to.equal(testValue3); + + ls.global.delete(3, "test.value"); + expect( + ls.global.get("test.value"), + "Layer two has a value that should be returned." + ).to.equal(testValue2); + + ls.global.delete(2, "test.value"); + expect( + ls.global.get("test.value"), + "There isn't any value anymore so it should be undefined." + ).to.be.undefined; + }); + + it("Set, delete and has", function (): void { + const ls = new LayeredStorage<1 | 2 | 3 | 4, KV, KV>(); + + expect( + ls.global.has("test.value"), + "There is no value yet so it should be reported as empty." + ).to.be.false; + + ls.global.set(3, "test.value", testValue3); + expect( + ls.global.has("test.value"), + "There is one value so it should be reported as present." + ).to.be.true; + + ls.global.set(2, "test.value", testValue2); + expect( + ls.global.has("test.value"), + "There are two value so it should be reported as present." + ).to.be.true; + + ls.global.delete(2, "test.value"); + expect( + ls.global.has("test.value"), + "There is one value so it should be reported as present." + ).to.be.true; + + ls.global.delete(3, "test.value"); + expect( + ls.global.has("test.value"), + "There isn't any value anymore so it should be reported as empty." + ).to.be.false; + }); + + describe("Delete layer", function (): void { + it("Segment layer", function (): void { + const ls = new LayeredStorage< + 1 | 2 | 3, + { test: string }, + { test: string } + >(); + + const a = ls.openSegment("A"); + a.set(1, "test", "A1"); + a.set(2, "test", "A2"); + a.set(3, "test", "A3"); + + const b = ls.openSegment("B"); + b.set(1, "test", "B1"); + b.set(2, "test", "B2"); + b.set(3, "test", "B3"); + + expect(a.get("test"), "The initial data should be set.").to.equal("A3"); + expect(b.get("test"), "The initial data should be set.").to.equal("B3"); + + b.deleteLayer(2); + + expect(a.get("test"), "Other segments shouldn't be affected.").to.equal( + "A3" + ); + expect( + b.get("test"), + "The 2nd layer is gone but the 3rd is still in place and should be returned." + ).to.equal("B3"); + + b.deleteLayer(3); + + expect(a.get("test"), "Other segments shouldn't be affected.").to.equal( + "A3" + ); + expect( + b.get("test"), + "The 3rd and 2nd layers has been deleted, the 1st layer should be returned." + ).to.equal("B1"); + }); + + it("Global layer", function (): void { + const ls = new LayeredStorage< + 1 | 2 | 3, + { test: string }, + { test: string } + >(); + + ls.global.set(1, "test", "g1"); + ls.global.set(2, "test", "g2"); + ls.global.set(3, "test", "g3"); + + const a = ls.openSegment("A"); + a.set(1, "test", "A1"); + + expect( + ls.global.get("test"), + "The initial data should be set." + ).to.equal("g3"); + expect(a.get("test"), "The initial data should be set.").to.equal("g3"); + + ls.global.deleteLayer(2); + + expect( + ls.global.get("test"), + "The 2nd global layer is gone but the 3rd is still in place and should be returned." + ).to.equal("g3"); + expect( + a.get("test"), + "The 2nd global layer is gone but the 3rd is still in place and should be returned." + ).to.equal("g3"); + + ls.global.deleteLayer(3); + + expect( + ls.global.get("test"), + "The 3rd and 2nd global layers are gone, the global 1st layer value should be returned." + ).to.equal("g1"); + expect( + a.get("test"), + "The 3rd and 2nd global layers are gone, this segment has it's own 1st layer value which should be returned." + ).to.equal("A1"); + }); + }); + }); +} diff --git a/test/layered-storage/other.ts b/test/layered-storage/other.ts new file mode 100644 index 000000000..acaf21038 --- /dev/null +++ b/test/layered-storage/other.ts @@ -0,0 +1,147 @@ +import { LayeredStorage } from "../../src/layered-storage"; +import { expect } from "chai"; + +type KV = Record; + +/** + * Other tests that don't fit elsewhere. + */ +export function other(): void { + const getStructCount = (ls: LayeredStorage<1 | 4 | 9, KV, KV>): number => + [ + // Ignore private property access errors. It's no big deal since this + // is a unit test. + // @ts-ignore + ...ls._core._data.values(), + ].reduce((acc, lData): number => { + return ( + acc + + 1 + + [...lData.values()].reduce((acc, sData): number => { + return acc + 1 + sData.size; + }, 0) + ); + }, 0); + + const getCacheSize = (ls: LayeredStorage<1 | 4 | 9, KV, KV>): number => + // Ignore private property access errors. It's no big deal since this + // is a unit test. + // @ts-ignore + ls._core._topLevelCache.size; + + it("Empty data structure purging", function (): void { + const ls = new LayeredStorage<1 | 4 | 9, KV, KV>(); + + ([1, 4, 9] as const).forEach((layer): void => { + ls.global.set(layer, "test.value1", 1); + ls.global.set(layer, "test.value2", 2); + ["a", "b", "c"].forEach((segment): void => { + ls.openSegment(segment).set(layer, "test.value1", 1); + ls.openSegment(segment).set(layer, "test.value2", 2); + }); + }); + + expect(getStructCount(ls)).to.equal( + 3 + // layers + 3 * 4 + // 4 segments on each layer + 3 * 4 * 2 // 2 values in each segment + ); + + ([1, 4, 9] as const).forEach((layer): void => { + ls.global.delete(layer, "test.value1"); + ["a", "b", "c"].forEach((segment): void => { + ls.openSegment(segment).delete(layer, "test.value1"); + }); + }); + + expect(getStructCount(ls)).to.equal( + 3 + // layers + 3 * 4 + // 4 segments on each layer + 3 * 4 * 1 // 1 value in each segment + ); + + ([1, 4, 9] as const).forEach((layer): void => { + ls.global.delete(layer, "test.value2"); + ["b"].forEach((segment): void => { + ls.openSegment(segment).delete(layer, "test.value2"); + }); + }); + + expect(getStructCount(ls)).to.equal( + 3 + // layers + 3 * 2 + // 2 segments on each layer + 3 * 2 * 1 // 1 value in each segment + ); + + ([1, 4, 9] as const).forEach((layer): void => { + ["a", "c"].forEach((segment): void => { + ls.openSegment(segment).delete(layer, "test.value2"); + }); + }); + + expect(getStructCount(ls)).to.equal( + 0 // no layers, no segments, no values + ); + }); + + it("Cache purging", function (): void { + const ls = new LayeredStorage<1, KV, KV>(); + + expect(getCacheSize(ls)).to.equal(0); + + ls.global.set(1, "test.value1", 7); + ls.openSegment("a").set(1, "test.value1", 7); + ls.openSegment("b").set(1, "test.value1", 7); + ls.openSegment("c").set(1, "test.value1", 7); + + expect(getCacheSize(ls)).to.equal(0); + + ls.global.get("test.value1"); + ls.openSegment("a").get("test.value1"); + ls.openSegment("b").get("test.value1"); + ls.openSegment("c").get("test.value1"); + ls.global.get("test.value2"); + ls.openSegment("a").get("test.value2"); + ls.openSegment("b").get("test.value2"); + ls.openSegment("c").get("test.value2"); + + expect(getCacheSize(ls)).to.equal(4); + + ls.global.set(1, "test.value1", 7); + ls.openSegment("a").set(1, "test.value1", 7); + ls.openSegment("b").set(1, "test.value1", 7); + ls.openSegment("c").set(1, "test.value1", 7); + + expect(getCacheSize(ls)).to.equal(4); + + ls.global.set(1, "test.value2", 7); + + expect(getCacheSize(ls)).to.equal(0); + }); + + it("Empty data structure creation", function (): void { + const ls = new LayeredStorage<1 | 4 | 9, KV, KV>(); + + ls.openSegment("c").set(4, "test.value1", 1); + + ls.global.get("test.value1"); + ls.openSegment("b").get("test.value1"); + ls.global.has("test.value2"); + ls.openSegment("a").has("test.value2"); + + expect(getStructCount(ls)).to.equal( + 3 // 1 layer, 1 segment, 1 value + ); + }); + + it("Segment storage reports it's segment", function (): void { + const ls = new LayeredStorage<1 | 4 | 9, KV, KV>(); + + expect( + ls.openSegment("$"), + "Each segment should exposes a property with the name of the segment." + ) + .have.ownProperty("segment") + .that.equals("$"); + }); +} diff --git a/test/layered-storage/segmented-layer.ts b/test/layered-storage/segmented-layer.ts new file mode 100644 index 000000000..a31fcda6c --- /dev/null +++ b/test/layered-storage/segmented-layer.ts @@ -0,0 +1,216 @@ +import { LayeredStorage } from "../../src/layered-storage"; +import { deepFreeze } from "../helpers"; +import { expect } from "chai"; + +interface KV { + "test.value": { number: number; value: { string: string } }; + "unrelated.value": { number: number; value: { string: string } }; +} + +/** + * Test that values can be set accross segments and later retrieved. + */ +export function segmentedLayer(): void { + describe("Segmented layer", function (): void { + const testValueA: KV["test.value"] = deepFreeze({ + number: 1, + value: { string: "A" }, + }); + const testValueB: KV["test.value"] = deepFreeze({ + number: 2, + value: { string: "B" }, + }); + const testValueC: KV["test.value"] = deepFreeze({ + number: 3, + value: { string: "C" }, + }); + + const a = Symbol("A"); + const b = Symbol("B"); + const c = Symbol("C"); + + it("Get without set", function (): void { + const ls = new LayeredStorage<7, KV, KV>(); + + ls.global.set(7, "test.value", testValueA); + + expect( + ls.openSegment(b).get("test.value"), + "Global value should be used if the segment doesn't exist." + ).to.equal(testValueA); + }); + + it("Get without set after unrelated set", function (): void { + const ls = new LayeredStorage<7, KV, KV>(); + + ls.global.set(7, "test.value", testValueA); + ls.openSegment(b).set(7, "unrelated.value", testValueB); + + expect( + ls.openSegment(b).get("test.value"), + "Global value should be used if the segment doesn't have it's own." + ).to.equal(testValueA); + }); + + it("Set and get", function (): void { + const ls = new LayeredStorage<7, KV, KV>(); + + ls.openSegment(a).set(7, "test.value", testValueA); + ls.openSegment(b).set(7, "test.value", testValueB); + ls.openSegment(c).set(7, "test.value", testValueC); + + expect( + ls.global.get("test.value"), + "Only segmented values were set, this should be undefined." + ).to.be.undefined; + + expect( + ls.openSegment(a).get("test.value"), + "The A segment should return A test value." + ).to.equal(testValueA); + expect( + ls.openSegment(b).get("test.value"), + "The B segment should return B test value." + ).to.equal(testValueB); + expect( + ls.openSegment(c).get("test.value"), + "The C segment should return C test value." + ).to.equal(testValueC); + }); + + it("Set and has", function (): void { + const ls = new LayeredStorage<7, KV, KV>(); + + ls.openSegment(b).set(7, "test.value", testValueB); + + expect( + ls.global.has("test.value"), + "Only B segment was set and should be true." + ).to.be.false; + + expect( + ls.openSegment(a).has("test.value"), + "Only B segment was set and should be true." + ).to.be.false; + expect( + ls.openSegment(b).has("test.value"), + "Only B segment was set and should be true." + ).to.be.true; + expect( + ls.openSegment(c).has("test.value"), + "Only B segment was set and should be true." + ).to.be.false; + }); + + it("Set, delete and get", function (): void { + const ls = new LayeredStorage<7, KV, KV>(); + + expect( + ls.openSegment(c).get("test.value"), + "There is no value yet so it should be undefined." + ).to.be.undefined; + + ls.openSegment(c).set(7, "test.value", testValueC); + expect( + ls.openSegment(c).get("test.value"), + "Layer 7 segment C has a value that should be returned." + ).to.equal(testValueC); + + ls.openSegment(c).delete(7, "test.value"); + expect( + ls.openSegment(c).get("test.value"), + "There isn't any value anymore so it should be undefined." + ).to.be.undefined; + }); + + it("Set, delete and has", function (): void { + const ls = new LayeredStorage<7, KV, KV>(); + + expect( + ls.openSegment(c).get("test.value"), + "There is no value yet so it should be undefined." + ).to.be.undefined; + + ls.openSegment(c).set(7, "test.value", testValueC); + expect( + ls.openSegment(c).has("test.value"), + "Layer 7 segment C has a value therefore it should return true." + ).to.be.true; + + ls.openSegment(c).delete(7, "test.value"); + expect( + ls.openSegment(c).has("test.value"), + "There isn't any value anymore so it should return false." + ).to.be.false; + }); + + it("Export", function (): void { + interface LocalKV { + "test.deeply.nested.value": string; + "test.deeply.not-exported-value": string; + "test.deeply.value": string; + "test.value1": string; + "test.value2": string; + } + + const ls = new LayeredStorage<0 | 2, LocalKV, LocalKV>(); + + ls.global.runTransaction((transaction): void => { + transaction.set(0, "test.value1", "a value from global segment"); + transaction.set(0, "test.value2", "a value from global segment"); + }); + + ls.openSegment(a).runTransaction((transaction): void => { + transaction.set(2, "test.value1", "a value from different segment"); + transaction.set(2, "test.value2", "a value from different segment"); + }); + + ls.openSegment(c).runTransaction((transaction): void => { + transaction.set(0, "test.deeply.nested.value", "0tdnv"); + transaction.set(2, "test.deeply.nested.value", "2tdnv"); + transaction.set(2, "test.deeply.not-exported-value", "2tdn"); + transaction.set(2, "test.deeply.value", "2tdv"); + transaction.set(2, "test.value1", "2tv"); + }); + + expect( + ls + .openSegment(c) + .exportToObject([ + "test.deeply.nested.value", + "test.deeply.value", + "test.value1", + "test.value2", + ]), + "All requested values should be exported." + ).to.deep.equal({ + test: { + value1: "2tv", + value2: "a value from global segment", + deeply: { + nested: { + value: "2tdnv", + }, + value: "2tdv", + }, + }, + }); + }); + + describe("Invalid layer names", function (): void { + [undefined, null, "string", true, false, {}].forEach( + (layer: any): void => { + it("" + layer, function (): void { + const ls = new LayeredStorage<0, KV, KV>(); + + expect( + (): void => + void ls.openSegment(b).set(layer, "test.value", testValueB), + "Layers have to be ordered which is only possible with numbers as that's the only thing that has universally accepted indisputable order." + ).to.throw(); + }); + } + ); + }); + }); +} diff --git a/test/layered-storage/single-layer.ts b/test/layered-storage/single-layer.ts new file mode 100644 index 000000000..4a9177bc7 --- /dev/null +++ b/test/layered-storage/single-layer.ts @@ -0,0 +1,153 @@ +import { LS_DELETE, LayeredStorage } from "../../src/layered-storage"; +import { deepFreeze } from "../helpers"; +import { expect } from "chai"; + +interface KV { + "test.value": { number: number; value: { string: string } }; +} + +/** + * Test that values can be set and retrieved from single global layer. + */ +export function singleLayer(): void { + describe("Single layer", function (): void { + const testValue: KV["test.value"] = deepFreeze({ + number: 7, + value: { string: "test" }, + }); + + it("Set and get", function (): void { + const ls = new LayeredStorage<0, KV, KV>(); + + ls.global.set(0, "test.value", testValue); + expect( + ls.global.get("test.value"), + "The same value that was set should be returned." + ).to.equal(testValue); + }); + + it("Set and has", function (): void { + const ls = new LayeredStorage<0, KV, KV>(); + + ls.global.set(0, "test.value", testValue); + expect( + ls.global.has("test.value"), + "The value should be reported as present after being set." + ).to.be.true; + }); + + it("Set, delete and get", function (): void { + const ls = new LayeredStorage<0, KV, KV>(); + + expect( + ls.global.get("test.value"), + "There is no value yet so it should be undefined." + ).to.be.undefined; + + ls.global.set(0, "test.value", testValue); + expect( + ls.global.get("test.value"), + "The same value that was set should be returned." + ).to.equal(testValue); + + ls.global.delete(0, "test.value"); + expect( + ls.global.get("test.value"), + "Undefined should be returned for deleted values." + ).to.be.undefined; + }); + + it("Set, delete and has", function (): void { + const ls = new LayeredStorage<0, KV, KV>(); + + expect( + ls.global.has("test.value"), + "There is no value yet so it should be reported as empty." + ).to.be.false; + + ls.global.set(0, "test.value", testValue); + expect( + ls.global.has("test.value"), + "The value should be reported as present after being set." + ).to.be.true; + + ls.global.delete(0, "test.value"); + expect( + ls.global.has("test.value"), + "The value should be reported as not present after being deleted." + ).to.be.false; + }); + + it("LS_DELETE constant", function (): void { + const ls = new LayeredStorage<0, KV, KV>(); + + expect( + ls.global.has("test.value"), + "There is no value yet so it should be reported as empty." + ).to.be.false; + + ls.global.set(0, "test.value", testValue); + expect( + ls.global.has("test.value"), + "The value should be reported as present after being set." + ).to.be.true; + + ls.global.set(0, "test.value", LS_DELETE); + expect( + ls.global.has("test.value"), + "The value should be reported as not present after being deleted via the LS_DELETE constant." + ).to.be.false; + }); + + it("Export", function (): void { + interface LocalKV { + "test.deeply.nested.value": string; + "test.deeply.not-exported-value": string; + "test.deeply.value": string; + "test.value": string; + } + + const ls = new LayeredStorage<0 | 2, LocalKV, LocalKV>(); + + ls.global.set(0, "test.value", "0tv"); + ls.global.set(2, "test.deeply.nested.value", "2tdnv"); + ls.global.set(2, "test.deeply.value", "2tdv"); + ls.global.set(2, "test.deeply.not-exported-value", "2tdn"); + ls.global.set(0, "test.deeply.nested.value", "0tdnv"); + + expect( + ls.global.exportToObject([ + "test.deeply.nested.value", + "test.deeply.value", + "test.value", + ]), + "All requested values should be exported." + ).to.deep.equal({ + test: { + value: "0tv", + deeply: { + nested: { + value: "2tdnv", + }, + value: "2tdv", + }, + }, + }); + }); + + describe("Invalid layer names", function (): void { + [undefined, null, "string", true, false, {}].forEach( + (layer: any): void => { + it("" + layer, function (): void { + const ls = new LayeredStorage<0, KV, KV>(); + + expect( + (): void => void ls.global.set(layer, "test.value", testValue), + "Layers have to be ordered which is only possible with numbers as that's the only thing that has universally accepted indisputable order." + ).to.throw(); + }); + } + ); + }); + }); +} diff --git a/test/layered-storage/transactions.ts b/test/layered-storage/transactions.ts new file mode 100644 index 000000000..64c60849b --- /dev/null +++ b/test/layered-storage/transactions.ts @@ -0,0 +1,62 @@ +import { LayeredStorage, stringAtLeast } from "../../src/layered-storage"; +import { expect } from "chai"; + +interface KV { + "test.value": string; + "test.value1": string; + "test.value2": string; + "unrelated.value": string; +} + +/** + * Test transactions. + */ +export function transactions(): void { + describe("Transactions", function (): void { + it("Transaction shouldn't save anything before commit", function (): void { + const ls = new LayeredStorage<7, KV, KV>(); + + const transaction = ls.global.openTransaction(); + + transaction.set(7, "test.value", "Hi!"); + + expect( + ls.global.has("test.value"), + "The value shouldn't be set before commit" + ).to.be.false; + + transaction.commit(); + + expect(ls.global.has("test.value"), "The value shoud be set after commit") + .to.be.true; + }); + + it("Transaction shouldn't save anything before commit", function (): void { + const ls = new LayeredStorage<7, KV, KV>(); + + ls.setInvalidHandler((key, value, message): void => { + throw new TypeError( + "Invalid value was supplied: " + + JSON.parse(JSON.stringify({ key, value, message })) + ); + }); + + ls.setValidators("test.value1", [stringAtLeast(4)]); + ls.setValidators("test.value2", [stringAtLeast(4)]); + + const transaction = ls.global.openTransaction(); + + expect((): void => { + transaction.set(7, "test.value1", "Hi!"); + }, "The value should be validated right away").to.throw(); + transaction.set(7, "test.value2", "Hi there!"); + + transaction.commit(); + + expect( + ls.global.has("test.value1"), + "Invalid values shouldn't be committed if commit is called" + ).to.be.false; + }); + }); +} diff --git a/test/layered-storage/validation.ts b/test/layered-storage/validation.ts new file mode 100644 index 000000000..62defea07 --- /dev/null +++ b/test/layered-storage/validation.ts @@ -0,0 +1,109 @@ +import { + LayeredStorage, + boolean, + fail, + number, + numberInteger, + pass, + string, +} from "../../src/layered-storage"; +import { expect } from "chai"; + +interface KV { + "test.boolean": boolean; + "test.fail": any; + "test.integer": number; + "test.number": number; + "test.pass": any; + "test.string": string; +} + +/** + * Test that values can be set and retrieved from single global layer. + */ +export function validation(): void { + describe("Validation", function (): void { + [ + // No handler. + ["Default", false, null] as const, + // Handler that does nothing. + ["Ignore", false, (): void => {}] as const, + // Handler that throws. + [ + "Throw", + true, + (key: string, value: unknown, messages: string[]): void => { + throw new TypeError(`${key}: ${value} (${messages.join(", ")})`); + }, + ] as const, + ].forEach(([name, throws, handler]): void => { + describe(name, function (): void { + [ + [false, "test.boolean", 77] as const, + [false, "test.fail", "fail"] as const, + [false, "test.integer", "3.5"] as const, + [false, "test.integer", 3.5] as const, + [false, "test.string", undefined] as const, + [true, "test.boolean", true] as const, + [true, "test.integer", 77] as const, + [true, "test.number", 3.5] as const, + [true, "test.number", 77] as const, + [true, "test.pass", "pass"] as const, + [true, "test.string", "test"] as const, + ].forEach(([valid, key, value]): void => { + it(`${key}: ${value}`, function (): void { + const ls = new LayeredStorage<0, KV, KV>(); + + // Add handler. + if (handler != null) { + ls.setInvalidHandler(handler); + } + + // Add validators. + ls.setValidators("test.boolean", [boolean]); + ls.setValidators("test.fail", [fail]); + ls.setValidators("test.integer", [number, numberInteger]); + ls.setValidators("test.number", [number]); + ls.setValidators("test.pass", [pass]); + ls.setValidators("test.string", [string]); + + if (valid) { + expect((): void => { + ls.global.set(0, key, value); + }, "No error should be thrown for valid values.").to.not.throw(); + expect( + ls.global.get(key), + "Valid values should be saved in the storage." + ).to.equal(value); + } else { + if (throws) { + expect((): void => { + ls.global.set(0, key, value); + }, "If the handler throws the set should throw too.").to.throw( + TypeError + ); + } else { + expect((): void => { + ls.global.set(0, key, value); + }, "If the handler doesn't throw neither should the set.").to.not.throw(); + } + expect( + ls.global.has(key), + "Invalid values should not be saved in the storage." + ).to.be.false; + } + }); + }); + }); + }); + + it("Setting validators twice", function (): void { + const ls = new LayeredStorage<0, KV, KV>(); + + expect((): void => { + ls.setValidators("test.fail", [fail]); + ls.setValidators("test.fail", [pass]); + }, "Setting validators repeatedly without replace shoudn't be allowed.").to.throw(); + }); + }); +}