// Copyright (c) Jupyter Development Team. // Distributed under the terms of the Modified BSD License. import { ReadonlyPartialJSONValue } from '@lumino/coreutils'; import { ISignal, Signal } from '@lumino/signaling'; import { IDataConnector } from './interfaces'; import { IStateDB } from './tokens'; /** * The default concrete implementation of a state database. */ export class StateDB< T extends ReadonlyPartialJSONValue = ReadonlyPartialJSONValue > implements IStateDB { /** * Create a new state database. * * @param options - The instantiation options for a state database. */ constructor(options: StateDB.IOptions = {}) { const { connector, transform } = options; this._connector = connector || new StateDB.Connector(); if (!transform) { this._ready = Promise.resolve(undefined); } else { this._ready = transform.then(transformation => { const { contents, type } = transformation; switch (type) { case 'cancel': return; case 'clear': return this._clear(); case 'merge': return this._merge(contents || {}); case 'overwrite': return this._overwrite(contents || {}); default: return; } }); } } /** * A signal that emits the change type any time a value changes. */ get changed(): ISignal { return this._changed; } /** * Clear the entire database. */ async clear(): Promise { await this._ready; await this._clear(); } /** * Retrieve a saved bundle from the database. * * @param id - The identifier used to retrieve a data bundle. * * @returns A promise that bears a data payload if available. * * #### Notes * The `id` values of stored items in the state database are formatted: * `'namespace:identifier'`, which is the same convention that command * identifiers in JupyterLab use as well. While this is not a technical * requirement for `fetch()`, `remove()`, and `save()`, it *is* necessary for * using the `list(namespace: string)` method. * * The promise returned by this method may be rejected if an error occurs in * retrieving the data. Non-existence of an `id` will succeed with the `value` * `undefined`. */ async fetch(id: string): Promise { await this._ready; return this._fetch(id); } /** * Retrieve all the saved bundles for a namespace. * * @param filter - The namespace prefix to retrieve. * * @returns A promise that bears a collection of payloads for a namespace. * * #### Notes * Namespaces are entirely conventional entities. The `id` values of stored * items in the state database are formatted: `'namespace:identifier'`, which * is the same convention that command identifiers in JupyterLab use as well. * * If there are any errors in retrieving the data, they will be logged to the * console in order to optimistically return any extant data without failing. * This promise will always succeed. */ async list(namespace: string): Promise<{ ids: string[]; values: T[] }> { await this._ready; return this._list(namespace); } /** * Remove a value from the database. * * @param id - The identifier for the data being removed. * * @returns A promise that is rejected if remove fails and succeeds otherwise. */ async remove(id: string): Promise { await this._ready; await this._remove(id); this._changed.emit({ id, type: 'remove' }); } /** * Save a value in the database. * * @param id - The identifier for the data being saved. * * @param value - The data being saved. * * @returns A promise that is rejected if saving fails and succeeds otherwise. * * #### Notes * The `id` values of stored items in the state database are formatted: * `'namespace:identifier'`, which is the same convention that command * identifiers in JupyterLab use as well. While this is not a technical * requirement for `fetch()`, `remove()`, and `save()`, it *is* necessary for * using the `list(namespace: string)` method. */ async save(id: string, value: T): Promise { await this._ready; await this._save(id, value); this._changed.emit({ id, type: 'save' }); } /** * Return a serialized copy of the state database's entire contents. * * @returns A promise that resolves with the database contents as JSON. */ async toJSON(): Promise<{ readonly [id: string]: T }> { await this._ready; const { ids, values } = await this._list(); return values.reduce((acc, val, idx) => { acc[ids[idx]] = val; return acc; }, {} as { [id: string]: T }); } /** * Clear the entire database. */ private async _clear(): Promise { await Promise.all((await this._list()).ids.map(id => this._remove(id))); } /** * Fetch a value from the database. */ private async _fetch(id: string): Promise { const value = await this._connector.fetch(id); if (value) { return (JSON.parse(value) as Private.Envelope).v as T; } } /** * Fetch a list from the database. */ private async _list(namespace = ''): Promise<{ ids: string[]; values: T[] }> { const { ids, values } = await this._connector.list(namespace); return { ids, values: values.map(val => (JSON.parse(val) as Private.Envelope).v as T) }; } /** * Merge data into the state database. */ private async _merge(contents: StateDB.Content): Promise { await Promise.all( Object.keys(contents).map( key => contents[key] && this._save(key, contents[key]!) ) ); } /** * Overwrite the entire database with new contents. */ private async _overwrite(contents: StateDB.Content): Promise { await this._clear(); await this._merge(contents); } /** * Remove a key in the database. */ private async _remove(id: string): Promise { return this._connector.remove(id); } /** * Save a key and its value in the database. */ private async _save(id: string, value: T): Promise { return this._connector.save(id, JSON.stringify({ v: value })); } private _changed = new Signal(this); private _connector: IDataConnector; private _ready: Promise; } /** * A namespace for StateDB statics. */ export namespace StateDB { /** * A state database change. */ export type Change = { /** * The key of the database item that was changed. * * #### Notes * This field is set to `null` for global changes (i.e. `clear`). */ id: string | null; /** * The type of change. */ type: 'clear' | 'remove' | 'save'; }; /** * A data transformation that can be applied to a state database. */ export type DataTransform< T extends ReadonlyPartialJSONValue = ReadonlyPartialJSONValue > = { /* * The change operation being applied. */ type: 'cancel' | 'clear' | 'merge' | 'overwrite'; /** * The contents of the change operation. */ contents: Content | null; }; /** * Database content map */ export type Content = { [id: string]: T | undefined }; /** * The instantiation options for a state database. */ export interface IOptions< T extends ReadonlyPartialJSONValue = ReadonlyPartialJSONValue > { /** * Optional string key/value connector. Defaults to in-memory connector. */ connector?: IDataConnector; /** * An optional promise that resolves with a data transformation that is * applied to the database contents before the database begins resolving * client requests. */ transform?: Promise>; } /** * An in-memory string key/value data connector. */ export class Connector implements IDataConnector { /** * Retrieve an item from the data connector. */ async fetch(id: string): Promise { return this._storage[id]; } /** * Retrieve the list of items available from the data connector. * * @param namespace - If not empty, only keys whose first token before `:` * exactly match `namespace` will be returned, e.g. `foo` in `foo:bar`. */ async list(namespace = ''): Promise<{ ids: string[]; values: string[] }> { return Object.keys(this._storage).reduce( (acc, val) => { if (namespace === '' ? true : namespace === val.split(':')[0]) { acc.ids.push(val); acc.values.push(this._storage[val]); } return acc; }, { ids: [] as string[], values: [] as string[] } ); } /** * Remove a value using the data connector. */ async remove(id: string): Promise { delete this._storage[id]; } /** * Save a value using the data connector. */ async save(id: string, value: string): Promise { this._storage[id] = value; } private _storage: { [key: string]: string } = {}; } } /* * A namespace for private module data. */ namespace Private { /** * An envelope around a JSON value stored in the state database. */ export type Envelope = { readonly v: ReadonlyPartialJSONValue }; }