├── .github └── FUNDING.yml ├── .gitignore ├── .npmignore ├── LICENSE ├── README.md ├── dist ├── client-web-storage.min.js └── client-web-storage.min.js.map ├── documentation ├── api-references │ ├── AppState.md │ ├── ClientStore.md │ ├── Schema.md │ └── SchemaValue.md └── docs.md ├── jest.config.js ├── package-lock.json ├── package.json ├── scripts └── build.ts ├── src ├── AppState.spec.ts ├── AppState.ts ├── ClientStore.spec.ts ├── ClientStore.ts ├── CustomTypes │ ├── ArrayOf.ts │ ├── CustomType.ts │ ├── Null.ts │ ├── OneOf.spec.ts │ ├── OneOf.ts │ └── SchemaId.ts ├── MemoryStore.spec.ts ├── MemoryStore.ts ├── Schema.spec.ts ├── Schema.ts ├── SchemaValue.spec.ts ├── SchemaValue.ts ├── client.ts ├── default-config.ts ├── helpers │ ├── use-app-state.tsx │ ├── use-client-store.tsx │ └── with-client-store.ts ├── index.ts ├── types.ts └── utils │ ├── error-messages.ts │ ├── generate-uuid.ts │ ├── get-default-value.ts │ ├── get-schema-type-and-default-value-from-value.spec.ts │ ├── get-schema-type-and-default-value-from-value.ts │ ├── is-empty-string.ts │ ├── is-nil.ts │ ├── is-object-literal.ts │ ├── is-of-supported-type.spec.ts │ ├── is-of-supported-type.ts │ ├── is-same-value-type.spec.ts │ ├── is-same-value-type.ts │ ├── is-supported-type-value.ts │ ├── is-supported-type.ts │ ├── is-valid-object-literal.ts │ ├── object-to-schema.spec.ts │ └── object-to-schema.ts └── tsconfig.json /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [beforesemicolon] 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist/* 12 | !dist/client-web-storage.min.js 13 | !dist/client-web-storage.min.js.map 14 | *.local 15 | *.tgz 16 | 17 | # Editor directories and files 18 | .vscode/* 19 | !.vscode/extensions.json 20 | .idea 21 | .DS_Store 22 | *.suo 23 | *.ntvs* 24 | *.njsproj 25 | *.sln 26 | *.sw? 27 | 28 | # test 29 | coverage/* 30 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | *.local 2 | # Editor directories and files 3 | .vscode/* 4 | !.vscode/extensions.json 5 | .idea 6 | .DS_Store 7 | *.suo 8 | *.ntvs* 9 | *.njsproj 10 | *.sln 11 | *.sw? 12 | src 13 | tsconfig.json 14 | documentation 15 | **.tgz 16 | **/*.spec.ts 17 | coverage 18 | jest.config.js 19 | .github 20 | **.js.map 21 | index.html 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Before Semicolon 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Client Web Storage 2 | Powerful Web Application Data Storage and State Management Solution. 3 | 4 | [![npm](https://img.shields.io/npm/v/client-web-storage)](https://www.npmjs.com/package/client-web-storage) 5 | 6 | - Same simple API for [IndexedDB](), [LocalStorage](), [WebSQL](), and in-memory data storage; 7 | - Event driven and asynchronous; 8 | - Automatic data validation done at the store level - ***Guarantees that all data fields are of the right type and exists with configurable automatic defaults;*** 9 | - No actions or reducers setup needed - ***The easiest store to configure ever***; 10 | - Easy setup for Client-Server data synchronization using [interceptors](); 11 | - **NOT UI framework specific!** Works with any UI Framework (React, Angular, VueJs, etc) - ***Take your storage setup with you when you migrate to a different framework and eliminate the need to learn a new state management solution for your app.*** 12 | - Easy to maintain and perform all data logic and fetching away from your components - ***Keep data concerns away from UI side of your app;*** 13 | - Highly and easily configurable; 14 | - Easy to tap into any store events to perform side effect logic; 15 | 16 | ## Quick Example 17 | 18 | ```ts 19 | // todo.store.ts 20 | 21 | import {ClientStore} from "client-web-storage"; 22 | 23 | interface ToDo { 24 | name: string; 25 | description: string; 26 | complete: boolean; 27 | } 28 | 29 | // create a store providing the name and schema object 30 | // with default values or javasctipt types 31 | const todoStore = new ClientStore("todo", { 32 | $name: String, 33 | description: "No Description", 34 | complete: false 35 | }); 36 | ``` 37 | 38 | Works with any web library or framework. Here is an example using React. 39 | 40 | ```ts 41 | // app.tsx 42 | 43 | import {useClientStore} from "client-web-storage/helpers/use-client-store"; 44 | import {Todo} from "./stores/todo.store"; 45 | import FlatList from "flatlist-react"; 46 | 47 | const App = () => { 48 | const todos = useClientStore("todo"); 49 | 50 | if(todos.processing) { 51 | return 52 | } 53 | 54 | if(todos.error) { 55 | return

{error.message}

56 | } 57 | 58 | const handleCreatItem = async () => { 59 | await todos.createItem({ 60 | // only name is required (marked with $), the store will auto fill the other fields with defaults 61 | name: "Go to Gym" 62 | }); 63 | } 64 | 65 | return ( 66 | <> 67 | 68 | 69 | 70 | ) 71 | } 72 | ``` 73 | 74 | ## Installation 75 | 76 | ### In Node Projects: 77 | 78 | ```bash 79 | npm install client-web-storage 80 | ``` 81 | 82 | ```js 83 | import {Schema, ClientStore} from "client-web-storage"; 84 | ``` 85 | 86 | ### In the Browser 87 | 88 | ```html 89 | 90 | 91 | 92 | 93 | 94 | ``` 95 | 96 | ```html 97 | 100 | ``` 101 | 102 | ## Documentation 103 | 104 | [Documentation](https://github.com/beforesemicolon/client-web-storage/blob/main/documentation/docs.md) 105 | 106 | #### Application Examples 107 | - [React](https://github.com/beforesemicolon/client-web-storage-project-examples/tree/main/react); 108 | - [Angular](https://github.com/beforesemicolon/client-web-storage-project-examples/tree/main/angular); 109 | 110 | [-- Check them All ---](https://github.com/beforesemicolon/client-web-storage-project-examples) 111 | 112 | #### API References 113 | - **[ClientStore](https://github.com/beforesemicolon/client-web-storage/blob/main/documentation/api-references/ClientStore.md)** 114 | - **[Schema](https://github.com/beforesemicolon/client-web-storage/blob/main/documentation/api-references/Schema.md)** 115 | - **[SchemaValue](https://github.com/beforesemicolon/client-web-storage/blob/main/documentation/api-references/SchemaValue.md)** 116 | -------------------------------------------------------------------------------- /documentation/api-references/AppState.md: -------------------------------------------------------------------------------- 1 | # AppState 2 | Object which handles and tracks the state of your application or part of it. 3 | 4 | ## Arguments 5 | 6 | ```ts 7 | new AppState(storeName, schema) 8 | ``` 9 | 10 | ### storeName 11 | Name of the app state. 12 | 13 | #### Type: `string` 14 | #### Required: TRUE 15 | 16 | ### schema 17 | A [schema instance]() of [schema object](). 18 | 19 | #### Type: `Schema | SchemaObjectLiteral` 20 | #### Required: TRUE 21 | 22 | ## Errors 23 | 24 | Inherits all error states from [ClientStore](https://github.com/beforesemicolon/client-web-storage/blob/main/documentation/api-references/ClientStore.md) 25 | 26 | ## Properties 27 | | Name | Description | Type | 28 | |-------|---------------------------------------------|-------------------------| 29 | | name | name given to the state | `string` | 30 | | value | Contains the state value of the application | `T` as in `AppState` | 31 | 32 | ## Methods 33 | | Name | Description | Arguments | 34 | |------------|-----------------------------------------------------------------------------------------------------------|------------------------------------------------------------| 35 | | update | Method used to update the state of the application | `Partial` | 36 | | subscribe | Method used to subscribe to the state of the application | `(data: T, error: ActionEventData | null) => void` | 37 | | intercept | Method used to intercept the state of the application to perform things like validation or transformation | `(data: T) => void` | 38 | 39 | ## Examples 40 | 41 | ```ts 42 | interface State { 43 | theme: "light" | "dark"; 44 | language: "en" | "pt"; 45 | } 46 | 47 | const appState = new AppState("todo", { 48 | theme: "light", 49 | language: "en", 50 | }); 51 | 52 | appState.update({ 53 | theme: "dark" 54 | }) 55 | ``` 56 | -------------------------------------------------------------------------------- /documentation/api-references/ClientStore.md: -------------------------------------------------------------------------------- 1 | # ClientStore 2 | A client store is a single document defined by its schema. It can be of different types which means, you can choose to 3 | store different data in a different way and use the same API to interact with the data regardless of the type. 4 | 5 | ## Arguments 6 | 7 | ```ts 8 | new ClientStore(storeName, schema, config) 9 | ``` 10 | 11 | ### storeName 12 | Name of the store. 13 | 14 | #### Type: `string` 15 | #### Required: TRUE 16 | 17 | ### schema 18 | A [schema instance]() of [schema object](). 19 | 20 | #### Type: `Schema | SchemaObjectLiteral` 21 | #### Required: TRUE 22 | 23 | ### config 24 | Store configuration object 25 | 26 | #### Type: `Config` 27 | #### Required: FALSE 28 | #### Default Value: `defaultConfig` 29 | #### Fields 30 | - `appName`: name of the app. Think about it like database and the stores like tables or documents in the database; 31 | - `version`: version of the store. Used for `INDEXEDDB` and `WEBSQL` store types 32 | - `type`: Determines how the data is saved in the browser. Valid values accessed via importing `StorageType`: 33 | - `LOCALSTORAGE`; 34 | - `WEBSQL`; 35 | - `INDEXEDDB`; 36 | - `MEMORYSTORAGE` 37 | - `description`: Used to describe the store and can be useful for developers; 38 | - `idKeyName`: A key name to override the default store identifier key name. [Learn more](https://github.com/beforesemicolon/client-web-storage/blob/main/documentation/docs.md#data-types) 39 | - `createdDateKeyName`: A key name to override the default store create date key name. [Learn more](https://github.com/beforesemicolon/client-web-storage/blob/main/documentation/docs.md#data-types) 40 | - `createdDateKeyName`: A key name to override the default store update date key name. [Learn more](https://github.com/beforesemicolon/client-web-storage/blob/main/documentation/docs.md#data-types) 41 | 42 | ```ts 43 | interface Config { 44 | appName?: string; 45 | version?: number; 46 | type?: string; 47 | description?: string; 48 | idKeyName?: string; 49 | createdDateKeyName?: string; 50 | createdDateKeyName?: string; 51 | } 52 | ``` 53 | 54 | ## Properties 55 | | Name | Description | Type | 56 | |----------------------|-------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------| 57 | | `schema` | The `Schema` instance used to define the store data | `Schema` | 58 | | `ready` | Whether the store has initialized and its ready | `boolean` | 59 | | `type` | How and where the data will be stored `Default: MEMORYSTORAGE` | `LOCALSTORAGE | WEBSQL | INDEXEDDB | MEMORYSTORAGE` | 60 | | `name` | The name you gave to the store when initializing it | `string` | 61 | | `appName` | The name of the app you provided in configuration. `Default: App` | `string` | 62 | | `processing` | A flag whether the store is currently processing some action | `boolean` | 63 | | `processingEvents` | A list of all events the store is currently processing | `EventType[]` | 64 | | `idKeyName` | The name of the key in the items used as identifier. `Default: _id` | `string` | 65 | | `createdDateKeyName` | The name of the key used to contain the date when the item was created. `Default: _createdDate` | `string` | 66 | | `updatedDateKeyName` | The name of the key used to contain the date when the item was last updated. `Default: __lastUpdatedDate` | `string` | 67 | 68 | ## Methods 69 | | Name | Description | Async | Arguments | Return | 70 | |----------------|---------------------------------------------|---------|-------------------------------------------------------------------------------|-----------------| 71 | | `size` | Get how many items are in the store | yes | `None` | `number` | 72 | | `subscribe` | Listen to all events of the store | no | `subscriber: StoreSubscriber` | `UnSubscriber` | 73 | | `on` | Listen to a specific event | no | `event: EventType.PROCESSING_EVENTS, handler: (event: EventType[]) => void` | `UnSubscriber` | 74 | | `off` | Stop listen to a specific event | no | `event: EventType.PROCESSING_EVENTS, handler: (event: EventType[]) => void` | `void` | 75 | | `beforeChange` | Intercept all actions to the store | no | `handler: BeforeChangeHandler` | `UnSubscriber` | 76 | | `intercept` | Intercept a specific action of the store | no | `event: EventType, handler: InterceptEventHandler` | `UnSubscriber` | 77 | | `loadItems` | Bulk create or update items into the store | yes | `dataList: Array>` | `Array` | 78 | | `createItem` | Create an item in the store | yes | `data: Partial` | `T` | 79 | | `updateItem` | Update an existing item in the store | yes | `id: string, data: Partial` | `T` | 80 | | `getItems` | Get all items in the store | yes | `None` | `T[]` | 81 | | `getItem` | Get a specific item from the store | yes | `id: string` | `T` | 82 | | `removeItem` | Remove a specific item in the store | yes | `id: string` | `string` | 83 | | `clear` | Clear all items in the store | yes | `None` | `string[]` | 84 | | `findItem` | Find a specific item in the store | yes | `cb?: (value: T, key: string) => boolean` | `T` | 85 | | `findItems` | Find a group of items in the store | yes | `cb?: (value: T, key: string) => boolean` | `T[]` | 86 | 87 | ## Errors 88 | 89 | ### ClientStore must have a non-blank name 90 | Error thrown when the store name is not provider 91 | 92 | ```ts 93 | new ClientStore() 94 | ``` 95 | 96 | ### Invalid "Schema" instance or object 97 | Error thrown when the schema is invalid 98 | 99 | ```ts 100 | new ClientStore("todo", null) 101 | ``` 102 | 103 | ### Received invalid "subscribe" handler 104 | Error thrown when you provide something other than a function to the `subscribe` method 105 | 106 | ### Received unknown {type} "{eventName}" event 107 | Error thrown when you provide invalid event name to `on`, `off`, or `interecept` methods 108 | 109 | ### Received unknown {type} "{eventName}" event handler 110 | Error thrown when you provide something other than a function to the `on`, `off`, `beforeChange` or `interecept` methods 111 | 112 | ### Invalid "value" provided to {action} item 113 | Error thrown when you provide non-object literal or empty object literal to perform `createItem`, `updateItem` and `loadItems` 114 | 115 | ### `Missing or invalid field "{key}" type 116 | Error thrown when a field value of the data is required but not provided or it is of a wrong type 117 | 118 | ## Examples 119 | 120 | ```ts 121 | const todoStore = new ClientStore("todo", { 122 | $name: String, 123 | description: "No Description" 124 | }) 125 | ``` 126 | 127 | ```ts 128 | const todoStore = new ClientStore("todo", { 129 | $name: String, 130 | description: "No Description", 131 | complete: false 132 | }, { 133 | type: INDEXEDDB, 134 | version: 2, 135 | appName: "Test", 136 | idKeyName: "id", 137 | createdDateKeyName: "dateCreated", 138 | updatedDateKeyName: "dateUpdated", 139 | }); 140 | ``` 141 | 142 | 143 | 144 | -------------------------------------------------------------------------------- /documentation/api-references/Schema.md: -------------------------------------------------------------------------------- 1 | # Schema 2 | A schema is an object type representation of the data you will be reading and putting in your store. 3 | 4 | ## Arguments 5 | 6 | ```ts 7 | new Schema(name, map) 8 | ``` 9 | 10 | ### name 11 | Name of the schema. 12 | 13 | #### Type: `string` 14 | #### Required: TRUE 15 | 16 | ### map 17 | An object literal with property field which values are `SchemaValue` instance. 18 | 19 | #### Type: `SchemaValueMap | null` 20 | #### Required: FALSE 21 | #### Default Value: `null` 22 | 23 | ## Properties 24 | | Name | Description | Type | 25 | |----------------------|-----------------------------------------------------------------------------------------------------|----------| 26 | | `name` | The name you gave to the schema when initializing it. Will match the store if accessed in the store | `string` | 27 | 28 | ## Methods 29 | | Name | Description | Async | Arguments | Return | 30 | |------------------------------|-----------------------------------------------------------------------|---------|-------------------------------------------------------------------------------------------------------------------------|---------------| 31 | | `defineField` | Define a field | no | `name: string, type: SchemaValueConstructorType | Schema, options: { defaultValue?: any, required?: boolean }` | `void` | 32 | | `removeField` | Remove a field definition | no | `name: string` | `void` | 33 | | `hasField` | Check if a field definition exists | no | `name: string` | `boolean` | 34 | | `getField` | Get a single field definition | no | `name: string` | `SchemaValue` | 35 | | `isValidFieldValue` | Check if a value is valid for a specific field | no | `name: string, value: any` | `boolean` | 36 | | `getInvalidSchemaDataFields` | Get a list of all invalid field given a object literal | no | `value: Record, defaultKeys: Set` | `string[]` | 37 | | `toJSON` | Get a `JSON` representation os the schema | no | `None` | `SchemaJSON` | 38 | | `toString` | Get a `string` representation os the schema | no | `None` | `string` | 39 | | `toValue` | Get an `Object` representation os the schema with default values | no | `None` | `T` | 40 | 41 | 42 | ## Errors 43 | 44 | ### Field "*" is not a SchemaValue 45 | Error thrown when the key value is not a `SchemaValue` instance 46 | 47 | ```ts 48 | // Symbol is not a SchemaValue instance 49 | new Schema('sample', { 50 | val: Symbol('invalid') 51 | }) 52 | ``` 53 | 54 | ## Examples 55 | 56 | ```ts 57 | const userSchema = new Schema("user", { 58 | name: new SchemaValue(String, true), 59 | avatar: new SchemaValue(String), 60 | }); 61 | 62 | const todoSchema = new Schema("todo", { 63 | name: new SchemaValue(String, true), 64 | id: new SchemaValue(SchemaId), 65 | dateCreated: new SchemaValue(Date), 66 | dateUpdated: new SchemaValue(Date), 67 | description: new SchemaValue(String, false, "No Description"), 68 | complete: new SchemaValue(Boolean), 69 | user: new SchemaValue(userSchema, true), 70 | }); 71 | ``` 72 | 73 | ```ts 74 | const userSchema = new Schema("user"); 75 | const todoSchema = new Schema("todo"); 76 | 77 | userSchema.defineField("name", String, {required: true}); 78 | 79 | todoSchema.defineField("name", String, {required: true}); 80 | todoSchema.defineField("description", String); 81 | todoSchema.defineField("complete", Boolean); 82 | todoSchema.defineField("user", userSchema, {required: true}); 83 | ``` 84 | -------------------------------------------------------------------------------- /documentation/api-references/SchemaValue.md: -------------------------------------------------------------------------------- 1 | # SchemaValue 2 | A schema value is the individual property of the schema. 3 | 4 | ## Arguments 5 | 6 | ```ts 7 | new SchemaValue(type, required, defaultValue) 8 | ``` 9 | 10 | ### type 11 | A supported value [Javascript type]() 12 | 13 | #### Type: `SchemaValueConstructorType | Schema` 14 | #### Required: TRUE 15 | 16 | ### required 17 | Whether the value is required 18 | 19 | #### Type: `boolean` 20 | #### Required: FALSE 21 | #### Default Value: `false` 22 | 23 | ### defaultValue 24 | 25 | #### Type: `SchemaValueType | SchemaJSON` 26 | #### Required: FALSE 27 | #### Default Value: `undefined` 28 | 29 | ## Properties 30 | None 31 | 32 | ## Methods 33 | | Name | Description | Async | Arguments | Return | 34 | |------------------------------|--------------------------------------------|---------|-----------|----------------| 35 | | `toJSON` | Get a `JSON` representation os the value | no | `None` | `JSONValue` | 36 | | `toString` | Get a `string` representation os the value | no | `None` | `string` | 37 | 38 | 39 | ## Errors 40 | 41 | ### Invalid SchemaValue type provided 42 | Error thrown when the type specified is not a [supported type](). 43 | 44 | ```ts 45 | // function is not a valid type 46 | new SchemaValue(Function) 47 | ``` 48 | 49 | ### Default value does not match type 50 | Error thrown when the default value does not match the type. 51 | 52 | ```ts 53 | // type is String but defaultValue is number 54 | new SchemaValue(String, false, 12) 55 | ``` 56 | 57 | ## Examples 58 | 59 | ```ts 60 | new SchemaValue(Schema, false, {}) 61 | ``` 62 | 63 | ````ts 64 | new SchemaValue(SchemaId, true) 65 | ```` 66 | 67 | ```ts 68 | new SchemaValue(ArrayOf(OneOf([String, Number], "")), false, [0, 1, 2]) 69 | ``` 70 | 71 | ```ts 72 | new SchemaValue(Array, true) 73 | ``` 74 | 75 | ```ts 76 | new SchemaValue(Int32Array) 77 | ``` 78 | 79 | ```ts 80 | new SchemaValue(Date) 81 | ``` 82 | 83 | ```ts 84 | const todoSchema = new Schema("todo"); 85 | 86 | new SchemaValue(todoSchema, true) 87 | ``` 88 | -------------------------------------------------------------------------------- /documentation/docs.md: -------------------------------------------------------------------------------- 1 | # Documentation 2 | 3 | ##### Table of Contents 4 | - [Creating a Store](#creating-a-store) 5 | - [Store Instance](#store-instance) 6 | - [Store App](#store-app) 7 | - [Storage Types](#storage-types) 8 | - [Store Versioning and Description](#store-versioning-and-description) 9 | - [Default Keys](#default-keys) 10 | - [Defining Schema](#defining-schema) 11 | - [Schema Object](#schema-object) 12 | - [Data Types](#data-types) 13 | - [Schema Instance](#schema-instance) 14 | - [CRUD the Store](#crud-the-store) 15 | - [Create](#create) 16 | - [Read](#read) 17 | - [Update](#update) 18 | - [Load](#load) 19 | - [Delete](#delete) 20 | - [Clear](#clear) 21 | - [Searching the Store](#searching-the-store) 22 | - [Event Handling](#event-handling) 23 | - [Event Types](#event-types) 24 | - [Subscription](#subscription) 25 | - [Event Listeners](#event-listeners) 26 | - [on](#on) 27 | - [off](#off) 28 | - [Event Interceptors](#event-interceptors) 29 | - [beforeChange](#beforechange) 30 | - [intercept](#intercept) 31 | - [Abort an Action](#abort-an-action) 32 | - [Validate Data Before Saving](#validate-data-before-saving) 33 | - [Update Store With API Returned Data](#update-store-with-api-returned-data) 34 | - [Transform Data Before Saving](#transform-data-before-saving) 35 | - [Managing App state](#managing-app-state) 36 | - [Accessing the State](#accessing-the-state) 37 | - [Update the State](#update-the-state) 38 | - [Subscribe to App State](#subscribe-to-app-state) 39 | - [Intercept App State](#intercept-app-state) 40 | - [Helpers](#helpers) 41 | - [useClientStore](#useclientstore) 42 | - [useAppState](#useappstate) 43 | - [withClientStore](#withclientstore) 44 | 45 | ## Creating a Store 46 | Think about a store as a document or table in a database. CWS provides a single way to create a store with [ClientStore](https://github.com/beforesemicolon/client-web-storage/blob/main/documentation/api-references/ClientStore.md) class. 47 | 48 | ```ts 49 | import {ClientStore, Schema} from "client-web-storage"; 50 | 51 | interface ToDo { 52 | name: string; 53 | description: string; 54 | complete: boolean; 55 | } 56 | 57 | // create a store providing the name and schema object 58 | // with default values or javasctipt types 59 | const todoStore = new ClientStore("todo", { 60 | $name: String, 61 | description: "No Description", 62 | complete: false 63 | }); 64 | ``` 65 | 66 | The [ClientStore](https://github.com/beforesemicolon/client-web-storage/blob/main/documentation/api-references/ClientStore.md) class takes 3 arguments: 67 | - Required [name of the store](https://github.com/beforesemicolon/client-web-storage/blob/main/documentation/api-references/ClientStore.md#storename); 68 | - Required [data schema](https://github.com/beforesemicolon/client-web-storage/blob/main/documentation/api-references/ClientStore.md#schema); 69 | - Optional [store configuration](https://github.com/beforesemicolon/client-web-storage/blob/main/documentation/api-references/ClientStore.md#config); 70 | 71 | The [ClientStore](https://github.com/beforesemicolon/client-web-storage/blob/main/documentation/api-references/ClientStore.md) allows you to define the store (table or document) as well as the app (database) it belongs to. 72 | 73 | An app can have multiple stores just like a database can have multiple documents or tables. The web application you create 74 | may also have multiple sub-apps. For example, you can have a web application which inside have a chat app, a widgets app and the 75 | rest is your application. 76 | 77 | CWS allows you to split these nicely to avoid having to mix data or deal with data from a different 78 | context. It does this by allowing you to control the [store instance](#store-instance) and [store app](#store-app). 79 | 80 | ### Store Instance 81 | A store instance is equivalent to a document or table in a database. The [ClientStore](https://github.com/beforesemicolon/client-web-storage/blob/main/documentation/api-references/ClientStore.md) returns an instance to the store 82 | if it already exists. For example, if you try to create 2 stores with the same name and [type](#storage-types), they will point to the same 83 | document/table but may have different schema. 84 | 85 | ```ts 86 | const todoStore1 = new ClientStore("todo", { 87 | $name: String 88 | }) 89 | const todoStore2 = new ClientStore("todo", { 90 | $name: String, 91 | description: "No Description" 92 | }) 93 | 94 | // only add item to one of them 95 | await todoStore2.createItem({ 96 | name: "sample" 97 | }) 98 | 99 | todoStore1 === todoStore2 // FALSE 100 | 101 | await todoStore1.size() // 1 102 | await todoStore2.size() // 1 103 | ``` 104 | 105 | As you can see above, the store instances are different and both stores have different schema, but they point to the same table 106 | or document in the database. The [storage type](#storage-types) must be the same for this behavior. 107 | 108 | This behavior is similar to document NoSQL databases like MongoDB and DynamoDB. Here you always define a schema for validation 109 | and data integrity but still can have items with different schema stored together. 110 | 111 | If you want strict schema tables, all you need to do is ensure all stores have unique names. 112 | 113 | CWS gives you the flexibility to follow a strict SQL and NoSQL database easily without having to change interface. You 114 | can take a look at the [store types](#storage-types) for even more granular control. 115 | 116 | ### Store App 117 | By default, all stores are part of the same app/database called `App`. 118 | 119 | You can create stores with same name in different apps (database) by specifying the `appName` in the configuration as 120 | the third argument to the [ClientStore](https://github.com/beforesemicolon/client-web-storage/blob/main/documentation/api-references/ClientStore.md). 121 | 122 | ```ts 123 | const todoStore1 = new ClientStore("todo", { 124 | $name: String 125 | }, { 126 | appName: "App1" 127 | }); 128 | const todoStore2 = new ClientStore("todo", { 129 | $name: String, 130 | description: "No Description" 131 | }, { 132 | appName: "App2" 133 | }); 134 | 135 | // only add item to one of them 136 | await todoStore2.createItem({ 137 | name: "sample" 138 | }) 139 | 140 | await todoStore1.size() // 0 141 | await todoStore2.size() // 1 142 | ``` 143 | 144 | As you can see above, although the stores have the same name, they now exist in different apps (database), therefore they 145 | do not point to the same document/table. 146 | 147 | ### Storage Types 148 | By default, all store's data will be kept in memory. That means that when you create a store, its storage type is `MEMORYSTORAGE`. 149 | 150 | ```ts 151 | // get all the types 152 | import {StorageType} from "client-web-storage"; 153 | ``` 154 | 155 | There are four storage types: 156 | - `MEMORYSTORAGE` - data stored in-memory 157 | - `LOCALSTORAGE` - data stored in [localStorage](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage) 158 | - `INDEXEDDB` - data stored in [IndexedDB](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API) 159 | - `WEBSQL` - data stored in [WebSQL](https://www.w3.org/TR/webdatabase/) 160 | 161 | You change the storage type by setting the `type` in the [ClientStore Configuration](https://github.com/beforesemicolon/client-web-storage/blob/main/documentation/api-references/ClientStore.md#config). 162 | 163 | ```ts 164 | const todo = new ClientStore("todo", { 165 | $name: String 166 | }, { 167 | type: StorageType.INDEXEDDB 168 | }); 169 | ``` 170 | 171 | Not all stores should be of same type. Some data you want to persist between sessions and others should disappear when 172 | the session is over. CWS allows you to control your data to the tiniest details and having the ability to decide 173 | how each piece of data is stored is crucial. 174 | 175 | ### Store Versioning and Description 176 | Based on the [storage type](#storage-types), versioning is important. For `INDEXEDDB` and `WEBSQL` stores you can set the version 177 | of your store which can be extremely useful to track changes in the schema and other store configurations. 178 | 179 | ```ts 180 | const todo = new ClientStore("todo", { 181 | $name: String 182 | }, { 183 | type: StorageType.INDEXEDDB, 184 | version: 1 185 | }); 186 | ``` 187 | 188 | If something about the store configuration or schema changes, bump the version. The next time the user loads your app 189 | the old stored data in the browser will not be considered, and you will avoid having to deal with data which no longer 190 | matches your updated app store schema. 191 | 192 | ```ts 193 | const todo = new ClientStore("todo", { 194 | $name: String, 195 | description: "No Description" // new schema key 196 | }, { 197 | type: StorageType.INDEXEDDB, 198 | version: 2 // update 199 | }); 200 | ``` 201 | 202 | ### Default Keys 203 | The stores you create will always give all items an unique identifier and track the time each item was created and last updated. 204 | 205 | The stores do that by setting `default keys` in each item. Be default they are: 206 | - `_id` 207 | - `_createdDate` 208 | - `_lastUpdatedDate` 209 | 210 | You can override these in configuration perhaps because you want these keys to match your data schema. You can do that 211 | in the store configuration without compromising the internal behavior around these keys. 212 | 213 | Let's say you have the following interface for each item: 214 | 215 | ```ts 216 | interface ToDo { 217 | id: string; 218 | dateCreated: Date; 219 | dateUpdated: Date; 220 | name: string; 221 | description: string; 222 | complete: boolean; 223 | } 224 | ``` 225 | 226 | If you use it to create your store, you will have to manually remember to set and track them on every action. 227 | That also does not prevent the store to create its own internal item keys. 228 | 229 | ```ts 230 | const todoStore = new ClientStore("todo", { 231 | $id: String, 232 | $dateCreated: Date, 233 | $dateUpdated: Date, 234 | $name: String, 235 | description: "No Description", 236 | complete: false 237 | }); 238 | 239 | await todoStore.createItem({ 240 | id: uuid(), 241 | dateCreated: new Date(), 242 | dateUpdated: new Date(), 243 | name: "Go to Gym" 244 | }); 245 | 246 | /* Creates item in the store 247 | { 248 | _id: "123e4567-e89b-12d3-a456-426614174000", 249 | _createdDate: "January, 4th 2022", 250 | _lastUpdatedDate: "January, 4th 2022", 251 | id: "123e4567-e89b-12d3-a456-426614174000", 252 | dateCreated: "January, 4th 2022", 253 | dateUpdated: "January, 4th 2022", 254 | name: "Go to Gym", 255 | description: "No Description", 256 | complete: false, 257 | } 258 | */ 259 | ``` 260 | 261 | We can improve this by overriding the store default key names and let the store handling things. 262 | 263 | ```ts 264 | const todoStore = new ClientStore("todo", { 265 | $name: String, 266 | description: "No Description", 267 | complete: false 268 | }, { 269 | idKeyName: "id", 270 | createdDateKeyName: "dateCreated", 271 | updatedDateKeyName: "dateUpdated", 272 | }); 273 | 274 | await todoStore.createItem({ 275 | name: "Go to Gym" 276 | }); 277 | 278 | /* Creates item in the store 279 | { 280 | id: "123e4567-e89b-12d3-a456-426614174000", 281 | dateCreated: "January, 4th 2022", 282 | dateUpdated: "January, 4th 2022", 283 | name: "Go to Gym", 284 | description: "No Description", 285 | complete: false, 286 | } 287 | */ 288 | ``` 289 | 290 | As you can see, you get the desired result while simplifying the way you interact with the store. 291 | 292 | ## Defining Schema 293 | A store schema is a way to: 294 | - Guarantee data format. (All fields will always exist with set or default values) 295 | - Validate data. The store will make sure required fields are always set and all fields have expected data types. 296 | 297 | One thing developers always be doing is setting default values and doing data checks when updating store data. CWS ensures 298 | that is done at the store level and developers can focus on other data logic. 299 | 300 | There are two ways to define the schema: 301 | - Create an [Object literal](#schema-object) (used in examples above); 302 | - Create a [Schema instance](#schema-instance); 303 | 304 | ### Schema Object 305 | A schema object is simply a Javascript object literal. Simply create a object literal representing your item interface. 306 | 307 | Given the following interface: 308 | ```ts 309 | interface ToDo { 310 | id: string; 311 | dateCreated: Date; 312 | dateUpdated: Date; 313 | name: string; 314 | description: string; 315 | complete: boolean; 316 | user: { 317 | name: string; 318 | avater: string; 319 | } 320 | } 321 | ``` 322 | 323 | Create the schema Object: 324 | 325 | ```ts 326 | const ToDoSchema = { 327 | id: SchemaId, 328 | dateCreated: Date, 329 | dateUpdated: Date, 330 | $name: String, 331 | description: "No Description", 332 | complete: false, 333 | user: { 334 | id: SchemaId, 335 | $name: String, 336 | avater: String, 337 | } 338 | } 339 | ``` 340 | As you can see the difference between the **typescript interface** and the **schema object** is minimal: 341 | - You use Javascript data type constructors instead (`String`, `Date`, `Boolean`, etc); 342 | - You can also use provided custom types from CWS (`SchemaId`, `ArrayOf`, `OneOf`); 343 | - Use the `$` sign to mark fields that user must provide on creation (in this case: `name`, `$user.name`); 344 | - Set a default value the store can use when a value is not provided (in this case `description` and `complete`); 345 | 346 | When setting the `description` the `"No Description"` is provided to the schema. The store will know that `descrioption` 347 | is of type `String` and that it is not required, therefore if when creating an item the `description` field is not 348 | specified, the store will use the value `"No Description"`. 349 | 350 | Same goes for the `complete` field. We could simply set the type to be `Boolean` and the default value for booleans is `false` 351 | (check [data types table](#data-types)) but we decided to explicitly set it to `false`. In this case the store will know that the 352 | field must be a `Boolean` and use `false` as default value when the field is not specified. 353 | 354 | ### Data Types 355 | As you can see above, the schema object uses javascript types plus additional CWS types to help you define the type 356 | of data for your store. 357 | 358 | Below is all supported types compared to `typescript` to show that the difference is minimal 359 | 360 | | Typescript Example | CWS/Javascript Example | Type | Store Default Value | 361 | |-------------------------------|-------------------------------------------|--------|---------------------------------| 362 | | `boolean` | `Boolean` | Native | `false` | 363 | | `string` | `String` | Native | `""` | 364 | | `number` | `Number` | Native | `0` | 365 | | `null` | `Null` | CWS | `null` | 366 | | `Date` | `Date` | Native | `null` | 367 | | `Array` | `Array` | Native | `[]` | 368 | | `Array` | `ArrayOf(String)` | CWS | `[]` | 369 | | `String | Number` | `OneOf([String, Number], defaultValue)` | CWS | `defaultValue` set | 370 | | `Array` | `ArrayOf(OneOf([String, Number], ""))` | CWS | `[]` | 371 | | `string` | `SchemaId` | CWS | `(new SchemaId()).defaultValue` | 372 | | `Record` | `Schema` | CWS | `{}` | 373 | | `Blob` | `Blob` | Native | `null` | 374 | | `ArrayBuffer` | `ArrayBuffer` | Native | `null` | 375 | | `Float32Array` | `Float32Array` | Native | `new Float32Array()` | 376 | | `Float64Array` | `Float64Array` | Native | `new Float64Array()` | 377 | | `Int8Array` | `Int8Array` | Native | `new Int8Array()` | 378 | | `Int16Array` | `Int16Array` | Native | `new Int16Array()` | 379 | | `Int32Array` | `Int32Array` | Native | `new Int32Array()` | 380 | | `Uint8Array` | `Uint8Array` | Native | `new Uint8Array()` | 381 | | `Uint8ClampedArray` | `Uint8ClampedArray` | Native | `new Uint8ClampedArray()` | 382 | | `Uint16Array` | `Uint16Array` | Native | `new Uint16Array()` | 383 | | `Uint32Array` | `Uint32Array` | Native | `new Uint32Array()` | 384 | 385 | ### Schema Instance 386 | The schema object will be converted to a `Schema` instance under the hood, and it is a much easier way to define a store 387 | instance. 388 | 389 | To create a schema simply instantiate the `Schema` class which takes a required name and optional schema map value. 390 | 391 | ```ts 392 | const todoSchema = new Schema("todo"); 393 | ``` 394 | 395 | Now given the follow todo interface: 396 | ```ts 397 | interface ToDo { 398 | id: string; 399 | dateCreated: Date; 400 | dateUpdated: Date; 401 | name: string; 402 | description: string; 403 | complete: boolean; 404 | user: { 405 | name: string; 406 | avater: string; 407 | } 408 | } 409 | ``` 410 | 411 | We can define our todo schema like so: 412 | 413 | ```ts 414 | const userSchema = new Schema("user"); 415 | 416 | userSchema.defineField("name", String, {required: true}); 417 | userSchema.defineField("avatar", String); 418 | 419 | const todoSchema = new Schema("todo"); 420 | 421 | todoSchema.defineField("name", String, {required: true}); 422 | todoSchema.defineField("id", SchemaId); 423 | todoSchema.defineField("dateCreated", Date); 424 | todoSchema.defineField("dateUpdated", Date); 425 | todoSchema.defineField("description", String, {defaultValue: "No Description"}); 426 | todoSchema.defineField("complete", Boolean); 427 | todoSchema.defineField("user", userSchema, {required: true}); 428 | ``` 429 | 430 | You may also define the fields during instantiation; 431 | 432 | ```ts 433 | const userSchema = new Schema("user", { 434 | name: new SchemaValue(String, true), 435 | avatar: new SchemaValue(String), 436 | }); 437 | 438 | const todoSchema = new Schema("todo", { 439 | name: new SchemaValue(String, true), 440 | id: new SchemaValue(SchemaId), 441 | dateCreated: new SchemaValue(Date), 442 | dateUpdated: new SchemaValue(Date), 443 | description: new SchemaValue(String, false, "No Description"), 444 | complete: new SchemaValue(Boolean), 445 | user: new SchemaValue(userSchema, true), 446 | }); 447 | ``` 448 | 449 | As you can see, dealing with the [Schema](https://github.com/beforesemicolon/client-web-storage/blob/main/documentation/api-references/Schema.md) and [SchemaValue](https://github.com/beforesemicolon/client-web-storage/blob/main/documentation/api-references/SchemaValue.md) is a little verbose and the reason the schema object 450 | is a much simpler way to define your store schema. This is to show what is the store is doing under the hood. 451 | 452 | You can always access the store schema via the `schema` property and if so, you should learn more about the [Schema](https://github.com/beforesemicolon/client-web-storage/blob/main/documentation/api-references/Schema.md) 453 | api. 454 | 455 | ```ts 456 | const todoStore = new ClientStore("todo", { 457 | $name: String, 458 | description: "No Description", 459 | complete: false 460 | }); 461 | 462 | todoStore.schema // return Schema instance 463 | ``` 464 | 465 | ## CRUD the Store 466 | Any store you create is asynchronous and event driven. This means that any operation you perform does not block execution 467 | and can be reacted to and intercepted. This makes any store unique and powerful to work with. 468 | 469 | Let's consider the following simple todo store: 470 | ```ts 471 | const todoStore = new ClientStore("todo", { 472 | $name: String, 473 | description: "No Description", 474 | complete: false 475 | }); 476 | ``` 477 | 478 | ### Create 479 | You can create any item by only providing the required fields and relying on the default values you set in the schema 480 | definition. 481 | 482 | ```ts 483 | await todoStore.createItem({ 484 | name: "Go to Gym" 485 | }); 486 | /* Creates 487 | { 488 | _id: "123e4567-e89b-12d3-a456-426614174000", 489 | _createdDate: "January, 4th 2022", 490 | _lastUpdatedDate: "January, 4th 2022", 491 | name: "Go to Gym", 492 | description: "No Description", 493 | complete: false, 494 | } 495 | */ 496 | 497 | await todoStore.createItem({ 498 | name: "Buy groceries", 499 | description: "Buy ingredients for the dinner tommorrow" 500 | }); 501 | 502 | /* Creates 503 | { 504 | _id: "123e4567-e89b-12d3-a456-426614174000", 505 | _createdDate: "January, 4th 2022", 506 | _lastUpdatedDate: "January, 4th 2022", 507 | name: "Buy groceries", 508 | description: "Buy ingredients for the dinner tommorrow", 509 | complete: false, 510 | } 511 | */ 512 | ``` 513 | 514 | The `createItem` method is asynchronous and returns the item if created or `null` if the action is [aborted](#abort-an-action). It takes 515 | an object partially representing the item you are creating. 516 | 517 | As you can see, even though you only specify a couple of properties, the store guarantees that all fields will exist 518 | by using the default values [based on type](#data-types) or that you specifically defined in your schema like we did with `description` 519 | and `complete`. 520 | 521 | ### Read 522 | You can always read the entire store or a single item with the methods `getItem` and `getItems`. 523 | 524 | ```ts 525 | await todoStore.getItems(); 526 | // return an array with all existing items 527 | 528 | await todoStore.getItem("123e4567-e89b-12d3-a456-426614174000"); 529 | // returns the item or null 530 | ``` 531 | 532 | ### Update 533 | The `updateItem` takes partial information to update the item in the store. It returns the updated item or `null` 534 | in case the action got aborted or the item does not exist in the store. 535 | 536 | ```ts 537 | await todoStore.updateItem("123e4567-e89b-12d3-a456-426614174000", { 538 | complete: true 539 | }); 540 | ``` 541 | 542 | ### Load 543 | There are times which you simply need to do a bulk update or item creation. The `loadItems` method allows you to do just that. 544 | 545 | It will create the item if it does not exist otherwise update it. 546 | 547 | ```ts 548 | // will create all items 549 | const items = await todoStore.loadItems([ 550 | { 551 | name: "Go to Gym" 552 | }, 553 | { 554 | name: "Buy groceries", 555 | description: "Buy ingredients for the dinner tommorrow" 556 | } 557 | ]); 558 | 559 | // will update all items 560 | await todoStore.loadItems(items.map(item => ({...item, complete: true}))) 561 | ``` 562 | 563 | This method always returns an array of items unless the action got aborted. In that case it returns `null`. 564 | 565 | ### Delete 566 | Whenever you want to remove a sinle item in the store, you call the `removeItem` with the id of the item 567 | 568 | ```ts 569 | await todoStore.removeItem("123e4567-e89b-12d3-a456-426614174000"); 570 | ``` 571 | 572 | This method will return the id of the item if succeeded, otherwise `null` if action got aborted or the item does not exist. 573 | 574 | ### Clear 575 | To clear the entire store, it is a simple as calling the `clear` method. 576 | 577 | ```ts 578 | await todoStore.clear(); 579 | ``` 580 | 581 | The `clear` method will return all the id of the item which got deleted or `null` in case the action got aborted. 582 | 583 | ## Searching the Store 584 | The CWS provides two methods to allow you to find any item in the store: `findItem` and `findItems`. 585 | 586 | They are both asynchronous and take a comparator function which must return a boolean whether it is a match or not. 587 | 588 | ```ts 589 | // find by name 590 | const item = await todoStore.findItem(item => item.name === "Go to Gym"); 591 | 592 | // find all completed items 593 | const items = await todoStore.findItems(item => item.complete); 594 | ``` 595 | 596 | ## Event Handling 597 | Any action you perform in a store can be: 598 | - aborted - cancel the action 599 | - intercepted - perform additional actions before they get to the store 600 | - subscribed to - perform action after they get in the store 601 | 602 | As you can see, you can perform actions before and after an item gets to the store. 603 | 604 | ### Event Types 605 | There are various store events you can tap into as you need to. 606 | - `READY` - the store got successfully initialized 607 | - `PROCESSING` - the store is performing single or multiple actions 608 | - `CREATED` - item was created 609 | - `REMOVED` - item was removed 610 | - `UPDATED` - item was updated 611 | - `LOADED` - items got loaded 612 | - `CLEARED` - store got cleared 613 | - `ERROR` - some error happened performing an action 614 | - `ABORTED` - an action got aborted 615 | 616 | Only CRUD operations can be intercepted, all of them can be subscribed to though. 617 | 618 | ### Subscription 619 | You may simply subscribe and unsubscribe to a store. 620 | 621 | ```ts 622 | const unsubscribe = todoStore.subscribe((eventType, dataAssociatedWithEvent) => { 623 | switch (eventType) { 624 | case ClientStore.EventType.READY: 625 | // handle event type here 626 | break; 627 | case ClientStore.EventType.CREATED: 628 | // handle event type here 629 | break; 630 | case ClientStore.EventType.UPDATED: 631 | // handle event type here 632 | break; 633 | case ClientStore.EventType.LOADED: 634 | // handle event type here 635 | break; 636 | case ClientStore.EventType.CLEARED: 637 | // handle event type here 638 | break; 639 | case ClientStore.EventType.REMOVED: 640 | // handle event type here 641 | break; 642 | case ClientStore.EventType.PROCESSING: 643 | // handle event type here 644 | break; 645 | case ClientStore.EventType.ABORTED: 646 | // handle event type here 647 | break; 648 | case ClientStore.EventType.ERROR: 649 | // handle event type here 650 | break; 651 | default: 652 | } 653 | }); 654 | 655 | unsubscribe(); 656 | ``` 657 | The data you receive in the un the subscription handler varies based on the event, so it is always great to check before 658 | performing any additional action. Because it is a subscription, the data is the result of an action after it happened. 659 | 660 | ### Event Listeners 661 | The `subscribe` method is nice because it provides you a single place to handle everything, but sometimes you only care 662 | about a specific action and rather subscribe to that action directly. 663 | 664 | For that there are the `on` and `off` methods that allows you to start and stop listening to specific events. 665 | 666 | #### on 667 | 668 | ```ts 669 | const stopListenToProcessingEvent = todoStore.on(EventType.PROCESSING, (processing: boolean) => { 670 | // side effect logic here 671 | }); 672 | 673 | stopListenToProcessingEvent(); 674 | ``` 675 | Above example uses the returned `off` function to clean the listener, but you may also call the `off` method yourself 676 | passing the same function instance as you can see bellow: 677 | 678 | #### off 679 | 680 | ```ts 681 | const handleProcessingEvent = (processing: boolean) => { 682 | // side effect logic here 683 | } 684 | 685 | todoStore.on(EventType.PROCESSING, handleProcessingEvent); 686 | 687 | todoStore.off(EventType.PROCESSING, handleProcessingEvent); 688 | ``` 689 | 690 | ### Event Interceptors 691 | Your stores come with the `intercept` and `beforeChange` methods which you can use to perform various things before the 692 | item is handled and saved in the store. These are called with the data the CRUD methods got called with to perform an action. 693 | 694 | This is useful to: 695 | - perform data validation; 696 | - call API and make sure the data is changed/created remotely before store is changed locally 697 | - perform data transformations; 698 | 699 | Both `intercept` and `beforeChange` handler functions can: 700 | - return `null` to abort an action; 701 | - return updated data to resume the action; 702 | - throw error to be caught by the store and trigger a `ERROR` event; 703 | 704 | #### beforeChange 705 | The `beforeChange` takes a handler function which will be called with the event and the data which any action got called with. 706 | 707 | ```ts 708 | const unsub = todoStore.beforeChange(async (eventType, data) => { 709 | switch (eventType) { 710 | case EventType.CREATED: 711 | // handle event type here 712 | break; 713 | case EventType.UPDATED: 714 | // handle event type here 715 | break; 716 | case EventType.LOADED: 717 | // handle event type here 718 | break; 719 | case EventType.REMOVED: 720 | // handle event type here 721 | break; 722 | case EventType.CLEARED: 723 | // handle event type here 724 | break; 725 | default: 726 | }; 727 | }); 728 | 729 | unsub() 730 | ``` 731 | 732 | As you can see, the handler function can be asynchronous which allows you to do whatever you want. 733 | 734 | #### intercept 735 | The `intercept` is similar to `beforeChange` method. The difference is that it allows you to intercept a specific event. 736 | 737 | ```ts 738 | const stopInterceptingCreateEvent = todoStore.intercept(EventType.CREATED, (data) => { 739 | // side effect logic here 740 | }); 741 | 742 | stopInterceptingCreateEvent(); 743 | ``` 744 | 745 | It is safe to throw an error inside the `intercept` and `beforeChange` handlers. The error is caught by the store and a 746 | `Error` event is created. 747 | 748 | This allows you to subscribe or listen to all errors in a single place and not worry about `try...catch` blocks inside 749 | handlers unless you really need to. 750 | 751 | #### Abort an Action 752 | To abort an action all you need to do is return `null` in the interceptors event handlers. 753 | 754 | Below example will make sure the store size will never be over 10 items. 755 | 756 | ```ts 757 | const stopInterceptingCreateEvent = todoStore.intercept(EventType.CREATED, async (data) => { 758 | if (await todoStore.size() === 10) { 759 | return null; 760 | } 761 | }); 762 | 763 | await todoStore.createItem({ 764 | name: "Go to Gym" 765 | }) 766 | 767 | stopInterceptingCreateEvent(); 768 | ``` 769 | 770 | #### Validate Data Before Saving 771 | The beauty of intercepting is that you can do whatever you need to do before ensuring the data is okay. Bellow is a simple 772 | example that will throw an error if the todo name is invalid. 773 | 774 | ```ts 775 | const removeErrorListerner = todoStore.on(EventType.ERROR, (error) => { 776 | displayAppErrorBanner(error.message); 777 | }) 778 | 779 | const stopInterceptingCreateEvent = todoStore.intercept(EventType.CREATED, async (data) => { 780 | if (isValidTodoName(data.name)) { 781 | await todoService.createTodo(data); 782 | } else { 783 | throw new Error('Invalid todo name') 784 | } 785 | }); 786 | 787 | try { 788 | await todoStore.createItem({ 789 | name: "$%$%$%$%$%$" 790 | }) 791 | } catch(e) { 792 | handleError(e); 793 | } 794 | 795 | // when no longer needed 796 | stopInterceptingCreateEvent(); 797 | removeErrorListerner(); 798 | ``` 799 | 800 | ##### Update Store With API Returned Data 801 | Sometimes you need to sync the current store data with the backend one. Perhaps you need the actual `id` generated in the backend 802 | and not the client one. 803 | 804 | ```ts 805 | const stopInterceptingCreateEvent = todoStore.intercept(EventType.CREATED, async (data) => { 806 | const res = await todoService.createTodo(data); 807 | 808 | // return new data to override the data 809 | // the action was called with 810 | return { 811 | ...res, 812 | _id: res.identifier, 813 | _lastUpdatedDate: res.updatedDate, 814 | } 815 | }); 816 | 817 | await todoStore.createItem({ 818 | name: "Go Shopping" 819 | }) 820 | 821 | // when no longer needed 822 | stopInterceptingCreateEvent(); 823 | ``` 824 | 825 | #### Transform Data Before Saving 826 | A common use case to intercepting data is to transform it before saving it. Perhaps the store actions are called with 827 | data which does not match the interface format which needs to be mapped or data which needs to be changed in some way 828 | before saving. 829 | 830 | ```ts 831 | const stopInterceptingCreateEvent = todoStore.intercept(EventType.CREATED, async (data) => { 832 | data.name = encode(data.name); 833 | data.description = encode(data.description); 834 | 835 | await todoService.createTodo(data); 836 | 837 | return data; // return the new data to override 838 | }); 839 | 840 | await todoStore.createItem({ 841 | name: "go to gym", 842 | description: "some unsafe data collected from user input" 843 | }) 844 | 845 | // when no longer needed 846 | stopInterceptingCreateEvent(); 847 | ``` 848 | 849 | ## Managing App state 850 | The [ClientStore](https://github.com/beforesemicolon/client-web-storage/blob/main/documentation/api-references/ClientStore.md) is perfect 851 | to handle data of your application in a list or database style. However, sometime you just need specific application data 852 | which are not necessarily the data of the users or that come from the server or that needs to be manipulated by your application. 853 | 854 | Such data are what we call metadata. They are things which helps you decide how to display the UI or how to behave. They 855 | are your application configuration and settings which can be global or specific to a part of your application. 856 | 857 | For such data you can't represent them as items in a store. For those you should not use `ClientStore`. That's why 858 | we have the `AppState` class to handle such things. 859 | 860 | ```ts 861 | interface State { 862 | theme: "light" | "dark"; 863 | language: "en" | "pt"; 864 | } 865 | 866 | const appState = new AppState("todo", { 867 | theme: "light", 868 | language: "en", 869 | }); 870 | ``` 871 | 872 | Above is a simple example on where to store metadata like the `theme` and `language` of the application. 873 | 874 | `AppState` inherits all the benefits of the `ClientStore`. It allows you to subscribe and intercept data. It also validates 875 | the state on every action allowing you to have full control of the state. 876 | 877 | ### Accessing the state 878 | 879 | To access the data you use the `value` property which returns the state at its current value. But the best 880 | way to be up-to-date with the state is by [subscribing to the store](#subscribe-to-app-state) 881 | 882 | ```ts 883 | appState.value; // returns the state 884 | ``` 885 | 886 | ### Update the state 887 | The `AppState` exposes the `update` method which is the only way to change the state. State fields cannot be removed or added 888 | after the initialization. You may only update their value. The store will set the defaults as necessary. 889 | 890 | ```ts 891 | appState.update({ 892 | theme: "dark" 893 | }) 894 | 895 | appState.update({ 896 | language: "pt" 897 | }) 898 | ``` 899 | 900 | ### Subscribe to App state 901 | You may always subscribe to the application state to react to every change. 902 | 903 | ```ts 904 | appState.subscribe((state) => { 905 | // handle state 906 | }) 907 | ``` 908 | 909 | ### Intercept App state 910 | Sometimes you need to perform validation or transformation on the state data before they make it in. For that you can 911 | use the `intercept` method. 912 | 913 | The `intercept` method of `AppState` is different from `ClientStore` in a sense that it does not take the event you 914 | want to subscribe to. You only need to provide the handler and like in the store, you: 915 | - return new data to override; 916 | - throw error to cancel action; 917 | - return null to abort the action in general; 918 | 919 | ```ts 920 | appState.intercet((dataUsedToUpdateTheState) => { 921 | // handle data 922 | }) 923 | ``` 924 | 925 | ## Helpers 926 | The `Client-Web-Storage` package exposes various helpers which are intended to help you incorporate the stores into 927 | you application much easier. 928 | 929 | ### useClientStore 930 | React helper that given a store instance or name, provides a store state which is much easier to interact or consume store 931 | data. 932 | 933 | It exposes a hook and a provider. 934 | ```ts 935 | import {useClientStore, ClientStoreProvider} from "client-web-storage/helpers/use-client-store"; 936 | ``` 937 | 938 | You can choose to inject all your stores at the top level of your app or section or your app 939 | 940 | ```ts 941 | const root = ReactDOM.createRoot( 942 | document.getElementById('root') as HTMLElement 943 | ); 944 | 945 | root.render( 946 | 947 | 948 | 949 | ); 950 | ``` 951 | 952 | Then simply consume the store like so: 953 | 954 | ```ts 955 | // app.tsx 956 | 957 | import {useClientStore} from "client-web-storage/helpers/use-client-store"; 958 | 959 | const App = () => { 960 | const todoStore = useClientStore("todo"); 961 | 962 | const handleCreateItem = async () => { 963 | await todoStore.createItem({ 964 | name: "todo-" + crypto.randomUUID() 965 | }) 966 | } 967 | 968 | return ( 969 | <> 970 |

Todos

971 | 972 | 973 | 974 | {todoStore.loadingItems 975 | ? 976 | : todoStore.error 977 | ? 978 | : todoStore.items.map(todo => )} 979 | 980 | ) 981 | } 982 | ``` 983 | 984 | ### useAppState 985 | React helper that given a app state instance or name, provides a store state which is much easier to interact or consume 986 | data. 987 | 988 | It exposes a hook and a provider. 989 | 990 | ```ts 991 | import {useAppState, AppStateProvider} from "client-web-storage/helpers/use-app-state"; 992 | ``` 993 | 994 | You can choose to inject all your stores at the top level of your app or section or your app 995 | 996 | ```ts 997 | const root = ReactDOM.createRoot( 998 | document.getElementById('root') as HTMLElement 999 | ); 1000 | 1001 | root.render( 1002 | 1003 | 1004 | 1005 | ); 1006 | ``` 1007 | 1008 | ```ts 1009 | // app.tsx 1010 | 1011 | import {useAppState} from "client-web-storage/helpers/use-app-state"; 1012 | 1013 | const App = () => { 1014 | const {state, setState, error} = useAppState(appState); 1015 | 1016 | ... 1017 | } 1018 | ``` 1019 | 1020 | ### withClientStore 1021 | A Higher Order Function which can be used with any UI framework to easily consume the store data. 1022 | 1023 | Bellow is an example on how to use it with Angular. 1024 | 1025 | ```ts 1026 | // app.component.ts 1027 | 1028 | import {StoreState} from "client-web-storage"; 1029 | import {withClientStore, DefaultStoreState} from "client-web-storage/helpers/with-client-store"; 1030 | import {todoStore, Todo} from "./stores/todo.store"; 1031 | 1032 | @Component({ 1033 | selector: 'app-root', 1034 | }) 1035 | export class AppComponent implements OnInit, OnDestroy { 1036 | $todo: StoreState = DefaultStoreState; 1037 | $unsubscribeFromTodoStore: UnSubscriber; 1038 | 1039 | ngOnInit() { 1040 | this.$unsubscribeFromTodoStore = withClientStore(todoStore, (data) => { 1041 | // handle data; 1042 | }); 1043 | } 1044 | 1045 | ngOnDestroy() { 1046 | this.$unsubscribeFromTodoStore(); 1047 | } 1048 | 1049 | } 1050 | ``` 1051 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ 2 | module.exports = { 3 | preset: 'ts-jest', 4 | testEnvironment: 'jsdom', 5 | transform: { 6 | "^.+\\.(ts)$": "ts-jest" 7 | } 8 | }; 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client-web-storage", 3 | "description": "Powerful Web Application Data Storage and State Management Solution.", 4 | "version": "1.7.14", 5 | "main": "./index.js", 6 | "types": "./index.d.ts", 7 | "repository": { 8 | "url": "https://github.com/beforesemicolon/client-web-storage", 9 | "type": "git" 10 | }, 11 | "keywords": [ 12 | "web-storage", 13 | "client-db", 14 | "client-storage", 15 | "indexed-db", 16 | "web-sql", 17 | "local-storage", 18 | "in-memory-storage" 19 | ], 20 | "author": "Elson Correia", 21 | "license": "MIT", 22 | "scripts": { 23 | "build": "ts-node scripts/build.ts", 24 | "local": "nodemon --watch src -e ts --exec 'npm run build:local && npm pack'", 25 | "test": "jest --coverage", 26 | "test:watch": "jest --watch" 27 | }, 28 | "devDependencies": { 29 | "@types/core-js": "^2.5.5", 30 | "@types/jest": "^27.4.1", 31 | "@types/jsdom": "^16.2.14", 32 | "@types/localforage": "0.0.34", 33 | "@types/node": "^18.11.18", 34 | "@types/react": "^18.0.27", 35 | "esbuild": "^0.25.0", 36 | "fake-indexeddb": "^4.0.1", 37 | "jest": "^27.5.1", 38 | "jest-canvas-mock": "^2.3.1", 39 | "jest-environment-jsdom": "^27.5.1", 40 | "nodemon": "^2.0.15", 41 | "react": "*", 42 | "ts-jest": "^27.1.4", 43 | "ts-node": "^10.9.1", 44 | "typescript": "^4.9.4" 45 | }, 46 | "dependencies": { 47 | "core-js": "^3.27.1", 48 | "localforage": "^1.10.0" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /scripts/build.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This script will handle putting the folder ready to be packaged for npm 3 | * 4 | * Parameters 5 | * - LOCAL => creates un-minified build 6 | * 7 | * !!!! It should be executed from the root directory 8 | */ 9 | import {exec} from 'child_process'; 10 | import {promisify} from 'util'; 11 | import packageJSON from '../package.json'; 12 | 13 | const execAsync = promisify(exec); 14 | 15 | const local = process.env.LOCAL === "true"; 16 | 17 | const clientCMD = local 18 | ? "esbuild src/client.ts --bundle --target=es2020 --outfile=dist/client-web-storage.min.js" 19 | : "esbuild src/client.ts --bundle --minify --keep-names --sourcemap --target=es2020 --outfile=dist/client-web-storage.min.js" 20 | 21 | async function run() { 22 | try { 23 | const tarFileName = `${packageJSON.name + "-" + packageJSON.version}.tgz`; 24 | await execAsync("rm -rf dist") 25 | await execAsync(clientCMD) 26 | await execAsync("tsc") 27 | await execAsync("cp README.md LICENSE package.json dist") 28 | await execAsync("cd dist && npm pack") 29 | await execAsync(`mv dist/${tarFileName} .`) 30 | 31 | console.log('Build Successful =>', tarFileName); 32 | 33 | const publishCommand = packageJSON.version.endsWith('-next') 34 | ? `npm publish ${tarFileName} --tag next` 35 | : `npm publish ${tarFileName}`; 36 | 37 | console.log('\nPublish with command:'); 38 | console.log(publishCommand); 39 | } catch (e) { 40 | console.error(e); 41 | } 42 | } 43 | 44 | run(); 45 | -------------------------------------------------------------------------------- /src/AppState.spec.ts: -------------------------------------------------------------------------------- 1 | import {AppState} from "./AppState"; 2 | 3 | interface State { 4 | foo: number; 5 | boo: string; 6 | } 7 | 8 | describe("AppState", () => { 9 | it('should create an app state', async () => { 10 | const appState = new AppState('app', { 11 | foo: 12, 12 | boo: String 13 | }); 14 | 15 | expect(appState.value.foo).toEqual(12); 16 | expect(appState.value.boo).toEqual(""); 17 | 18 | const subscriber = jest.fn(); 19 | 20 | const unsubscribe = appState.subscribe(subscriber); 21 | const stopIntercept = appState.intercept((data) => { 22 | if (data.foo && data.foo < 0) { 23 | data.foo = 0; 24 | } 25 | 26 | if (data.boo === "-") { 27 | throw new Error("Invalid") 28 | } 29 | }); 30 | 31 | let res = await appState.update({ 32 | foo: -8, 33 | boo: "sample" 34 | }) 35 | 36 | expect(res).toEqual({boo: "sample", foo: 0}); 37 | expect(subscriber).toHaveBeenCalledWith(res, null); 38 | subscriber.mockClear(); 39 | 40 | try { 41 | res = await appState.update({ 42 | foo: 100, 43 | boo: "-" 44 | }) 45 | } catch(e) { 46 | expect(e).toEqual(new Error("Invalid")) 47 | } 48 | 49 | expect(res).toEqual({boo: "sample", foo: 0}); 50 | expect(subscriber).toHaveBeenCalledWith(null, new Error("Invalid")); 51 | subscriber.mockClear(); 52 | 53 | unsubscribe(); 54 | stopIntercept(); 55 | 56 | res = await appState.update({ 57 | foo: -10, 58 | boo: "-", 59 | }); 60 | 61 | expect(subscriber).not.toHaveBeenCalled(); 62 | expect(subscriber).not.toHaveBeenCalled(); 63 | expect(res).toEqual({boo: "-", foo: -10}); 64 | }); 65 | }) 66 | -------------------------------------------------------------------------------- /src/AppState.ts: -------------------------------------------------------------------------------- 1 | import {ClientStore} from "./ClientStore"; 2 | import {Schema} from "./Schema"; 3 | import {ActionEventData, EventType, SchemaDefaultValues, SchemaObjectLiteral, UnSubscriber} from "./types"; 4 | 5 | export class AppState { 6 | #store: ClientStore; 7 | #item: T & SchemaDefaultValues = {} as T & SchemaDefaultValues; 8 | 9 | get value(): T { 10 | return this.#extractStateFromItem(this.#item); 11 | } 12 | 13 | constructor(public name: string, schema: Schema | SchemaObjectLiteral) { 14 | this.#store = new ClientStore(name, schema); 15 | this.#item = this.#store.schema.toValue() as T & SchemaDefaultValues; 16 | } 17 | 18 | async update(data: Partial) { 19 | if (this.#item._id) { 20 | return this.#store.updateItem(this.#item._id, { 21 | ...data 22 | }).then(item => { 23 | this.#item = item; 24 | return this.#extractStateFromItem(item); 25 | }) 26 | } 27 | 28 | // @ts-ignore 29 | return this.#store.createItem({ 30 | ...this.#item, 31 | ...data 32 | }).then(item => { 33 | this.#item = item; 34 | return this.#extractStateFromItem(item); 35 | }) 36 | } 37 | 38 | subscribe(handler: (data: T | null, error: Error | null) => void): UnSubscriber { 39 | return this.#store.subscribe((eventType, data) => { 40 | switch (eventType) { 41 | case EventType.CREATED: 42 | case EventType.UPDATED: 43 | handler(this.#extractStateFromItem(data as T), null); 44 | break; 45 | case EventType.ERROR: 46 | handler(null, (data as ActionEventData).error); 47 | break; 48 | } 49 | }) 50 | } 51 | 52 | intercept(handler: (data: T) => void): UnSubscriber { 53 | const unListenFromCreate = this.#store.intercept(EventType.CREATED, ({data}) => { 54 | handler(data); 55 | }) 56 | 57 | const unListenFromUpdate = this.#store.intercept(EventType.UPDATED, ({data}) => { 58 | handler(data); 59 | }) 60 | 61 | return () => { 62 | unListenFromCreate(); 63 | unListenFromUpdate(); 64 | } 65 | } 66 | 67 | #extractStateFromItem(data: T): T { 68 | const {_id, _createdDate, _lastUpdatedDate, ...state} = data as T & SchemaDefaultValues; 69 | return state as T; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/ClientStore.ts: -------------------------------------------------------------------------------- 1 | import 'core-js/actual/structured-clone'; 2 | import localforage from 'localforage'; 3 | import {Schema} from "./Schema"; 4 | import {MemoryStore} from "./MemoryStore"; 5 | import { 6 | ActionEventData, 7 | BeforeChangeHandler, 8 | Config, 9 | EventData, 10 | EventHandler, 11 | EventType, 12 | InterceptEventHandler, 13 | SchemaDefaultValues, 14 | SchemaObjectLiteral, 15 | StoreSubscriber, 16 | UnSubscriber 17 | } from "./types"; 18 | import {defaultConfig} from "./default-config"; 19 | import {SchemaId} from "./CustomTypes/SchemaId"; 20 | import {objectToSchema} from "./utils/object-to-schema"; 21 | import {errorMessages} from "./utils/error-messages"; 22 | import {isValidObjectLiteral} from "./utils/is-valid-object-literal"; 23 | import {isObjectLiteral} from "./utils/is-object-literal"; 24 | 25 | localforage.defineDriver(MemoryStore()); 26 | 27 | const deepClone = structuredClone; 28 | 29 | export class ClientStore { 30 | #store: LocalForage; 31 | #config: Config; 32 | #storeName: string; 33 | #schema: Schema; 34 | #subscribers: Set> = new Set(); 35 | #eventHandlers: Map>> = new Map(Object 36 | .values(EventType) 37 | .map((event) => [event as EventType, new Set()]) 38 | ); 39 | #interceptEventHandlers: Map | null> = new Map(Object 40 | .values(EventType) 41 | .map((event) => [event as EventType, null]) 42 | ); 43 | #beforeChangeHandler: BeforeChangeHandler | null = null; 44 | #ready = false; 45 | #processes: Set = new Set(); 46 | 47 | constructor(storeName: string, schema: Schema | SchemaObjectLiteral, config: Config = defaultConfig) { 48 | this.#storeName = storeName; 49 | this.#config = {...defaultConfig, ...config}; 50 | 51 | if (!`${storeName}`.trim().length) { 52 | throw new Error(errorMessages.blankStoreName()) 53 | } 54 | 55 | if (!(schema instanceof Schema)) { 56 | if (`${schema}` === '[object Object]') { 57 | schema = objectToSchema(storeName, schema); 58 | } else { 59 | throw new Error(errorMessages.invalidSchema()) 60 | } 61 | } 62 | 63 | const name = this.#config.appName || "App"; 64 | 65 | this.#schema = schema; 66 | this.#store = localforage.createInstance({ 67 | driver: this.#config.type, 68 | version: this.#config.version, 69 | description: this.#config.description, 70 | name, 71 | storeName, 72 | }); 73 | 74 | this.#store.ready(() => { 75 | this.#ready = true; 76 | this.#broadcast(EventType.READY, true); 77 | }) 78 | } 79 | 80 | get schema() { 81 | return this.#schema; 82 | } 83 | 84 | /** 85 | * whether the store has successfully loaded 86 | */ 87 | get ready() { 88 | return this.#ready; 89 | } 90 | 91 | /** 92 | * the type of the store 93 | */ 94 | get type() { 95 | return this.#config.type; 96 | } 97 | 98 | /** 99 | * name of the store 100 | */ 101 | get name() { 102 | return this.#storeName; 103 | } 104 | 105 | /** 106 | * name of the app store belongs to 107 | */ 108 | get appName() { 109 | return this.#config.appName; 110 | } 111 | 112 | get processing() { 113 | return this.#processes.size > 0; 114 | } 115 | 116 | /** 117 | * name of the item key used to id the items 118 | */ 119 | get idKeyName() { 120 | return (this.#config.idKeyName || defaultConfig.idKeyName) as keyof T; 121 | } 122 | 123 | /** 124 | * name of the item key used track time the item was created 125 | */ 126 | get createdDateKeyName() { 127 | return (this.#config.createdDateKeyName || defaultConfig.createdDateKeyName) as keyof T; 128 | } 129 | 130 | /** 131 | * name of the item key used track time the item was last updated 132 | */ 133 | get updatedDateKeyName() { 134 | return (this.#config.updatedDateKeyName || defaultConfig.updatedDateKeyName) as keyof T; 135 | } 136 | 137 | get processingEvents(): EventType[] { 138 | return Array.from(this.#processes, (event) => event.split('_')[0]) as EventType[]; 139 | } 140 | 141 | /** 142 | * the total count of items in the store 143 | */ 144 | async size() { 145 | return await this.#store.length(); 146 | } 147 | 148 | /** 149 | * subscribe to change in the store and react to them 150 | * @param sub 151 | */ 152 | subscribe(sub: StoreSubscriber): UnSubscriber { 153 | if (typeof sub === 'function') { 154 | this.#subscribers.add(sub) 155 | 156 | return () => { 157 | this.#subscribers.delete(sub) 158 | } 159 | } 160 | 161 | throw new Error(errorMessages.invalidSubHandler(sub)); 162 | } 163 | 164 | on(event: EventType.PROCESSING_EVENTS, handler: (event: EventType[]) => void): UnSubscriber 165 | on(event: EventType.PROCESSING, handler: (processing: boolean) => void): UnSubscriber 166 | on(event: EventType.READY, handler: (ready: boolean) => void): UnSubscriber 167 | on(event: EventType.CREATED, handler: (data: T) => void): UnSubscriber 168 | on(event: EventType.UPDATED, handler: (data: T) => void): UnSubscriber 169 | on(event: EventType.LOADED, handler: (dataList: T[]) => void): UnSubscriber 170 | on(event: EventType.REMOVED, handler: (id: string) => void): UnSubscriber 171 | on(event: EventType.CLEARED, handler: (ids: string[]) => void): UnSubscriber 172 | on(event: EventType.ABORTED, handler: (data: ActionEventData) => void): UnSubscriber 173 | on(event: EventType.ERROR, handler: (data: ActionEventData) => void): UnSubscriber 174 | on(event: EventType, handler: any): UnSubscriber { 175 | if (typeof handler === 'function') { 176 | if (this.#eventHandlers.has(event)) { 177 | this.#eventHandlers.get(event)?.add(handler); 178 | 179 | return () => this.off(event, handler); 180 | } 181 | 182 | throw new Error(errorMessages.invalidEventName("ON", event)); 183 | } 184 | 185 | throw new Error(errorMessages.invalidEventHandler("ON", event, handler)); 186 | } 187 | 188 | off(event: EventType, handler: EventHandler) { 189 | if (typeof handler === 'function') { 190 | if (this.#eventHandlers.has(event)) { 191 | this.#eventHandlers.get(event)?.delete(handler); 192 | return; 193 | } 194 | 195 | throw new Error(errorMessages.invalidEventName("OFF", event)); 196 | } 197 | 198 | throw new Error(errorMessages.invalidEventHandler("OFF", event, handler)); 199 | } 200 | 201 | /** 202 | * intercept actions before they are made to the store 203 | * to perform any action before it happens and gets broadcast as event 204 | * @param handler 205 | */ 206 | beforeChange(handler: BeforeChangeHandler): UnSubscriber { 207 | if (typeof handler === 'function') { 208 | this.#beforeChangeHandler = handler; 209 | } else { 210 | throw new Error(errorMessages.invalidEventHandler("function", "beforeChange", handler)); 211 | } 212 | 213 | return () => { 214 | this.#beforeChangeHandler = null; 215 | } 216 | } 217 | 218 | intercept(event: EventType.CREATED, handler: (item: { data: T, id: string }) => Partial | null | void | Promise | null | void>): UnSubscriber; 219 | intercept(event: EventType.UPDATED, handler: (item: { data: T, id: string }) => Partial | null | void | Promise | null | void>): UnSubscriber; 220 | intercept(event: EventType.LOADED, handler: (items: { data: T[], id: null }) => Array> | null | void | Promise> | null | void>): UnSubscriber; 221 | intercept(event: EventType.REMOVED, handler: (itemId: { data: string, id: string }) => null | void | Promise): UnSubscriber; 222 | intercept(event: EventType.CLEARED, handler: (itemIds: { data: string[], id: null }) => null | void | Promise): UnSubscriber; 223 | intercept(event: EventType, handler: any): UnSubscriber { 224 | if (typeof handler === 'function') { 225 | if (this.#interceptEventHandlers.has(event)) { 226 | this.#interceptEventHandlers.set(event, handler); 227 | 228 | return () => { 229 | this.#interceptEventHandlers.set(event, null); 230 | } 231 | } 232 | 233 | throw new Error(errorMessages.invalidEventName("INTERCEPT", event)); 234 | } 235 | 236 | throw new Error(errorMessages.invalidEventHandler("INTERCEPT", event, handler)); 237 | } 238 | 239 | /** 240 | * updates or creates items in the store from an optionally provided list or a list 241 | * returned by the LOADED interception handler 242 | * @param dataList 243 | * @return Array | null 244 | */ 245 | async loadItems(dataList: Array> = []): Promise & SchemaDefaultValues>> { 246 | return this.#trackProcess(EventType.LOADED, async () => { 247 | try { 248 | const newItems = new Map(); 249 | const {key} = this.#getDefaultKeys(); 250 | 251 | for (let data of dataList) { 252 | if (!isValidObjectLiteral(data)) { 253 | throw new Error(errorMessages.invalidValueProvided("load", data)) 254 | } 255 | 256 | const {item, id} = await this.#mergeExistingItemWithNewData(data) 257 | 258 | this.#validateData( 259 | item as T, 260 | (invalidFields) => errorMessages.missingOrInvalidFields(invalidFields, invalidFields.map(name => this.schema.getField(name) ?? undefined)) 261 | ); 262 | 263 | newItems.set(id, item); 264 | } 265 | 266 | let result = await this.#getWithResult(EventType.LOADED, Array.from(newItems.values())); 267 | 268 | if (result === null) { 269 | this.#broadcast(EventType.ABORTED, { 270 | action: EventType.LOADED, 271 | data: dataList 272 | }); 273 | 274 | return null; 275 | } 276 | 277 | if (Array.isArray(result) && result.length) { 278 | 279 | for (let itemValue of result) { 280 | if (!isObjectLiteral(itemValue)) { 281 | throw new Error(errorMessages.invalidValueInterceptProvided("load", itemValue)) 282 | } 283 | 284 | let itemId = itemValue[key]; 285 | 286 | if (newItems.get(itemValue[key])) { 287 | newItems.set(itemValue[key], deepClone({...newItems.get(itemValue[key]), ...itemValue})) 288 | } else { 289 | const {item, id} = await this.#mergeExistingItemWithNewData(itemValue); 290 | newItems.set(id, item); 291 | itemId = id; 292 | } 293 | 294 | this.#validateData( 295 | newItems.get(itemId), 296 | (invalidFields) => errorMessages.missingOrInvalidFields(invalidFields, invalidFields.map(name => this.schema.getField(name) ?? undefined)) 297 | ); 298 | } 299 | } 300 | 301 | const setItems = await Promise.all( 302 | Array.from(newItems.values()).map(newItem => this.#store.setItem(`${newItem[key]}`, newItem)) 303 | ); 304 | 305 | this.#broadcast(EventType.LOADED, setItems); 306 | 307 | return setItems 308 | 309 | } catch (error: any) { 310 | this.#broadcast(EventType.ERROR, this.#createEventData(EventType.LOADED, dataList, null, error)); 311 | throw error; 312 | } 313 | }); 314 | } 315 | 316 | /** 317 | * creates an item in the store given a partial item object 318 | * @param data 319 | * @return item | null 320 | */ 321 | async createItem(data: Partial): Promise & SchemaDefaultValues> { 322 | return this.#trackProcess(EventType.CREATED, async () => { 323 | try { 324 | if (!isValidObjectLiteral(data)) { 325 | throw new Error(errorMessages.invalidValueProvided("create", data)) 326 | } 327 | 328 | this.#validateData( 329 | data, 330 | (invalidFields) => errorMessages.missingOrInvalidFields(invalidFields, invalidFields.map(name => this.schema.getField(name) ?? undefined)) 331 | ); 332 | 333 | let {item, id} = await this.#mergeExistingItemWithNewData(data) 334 | 335 | const result = await this.#getWithResult(EventType.CREATED, item, id); 336 | 337 | if (result === null) { 338 | this.#broadcast(EventType.ABORTED, { 339 | action: EventType.CREATED, 340 | data: data 341 | }); 342 | 343 | return null; 344 | } 345 | 346 | if (isObjectLiteral(result)) { 347 | item = deepClone({...item, ...result}); 348 | 349 | this.#validateData( 350 | item, 351 | (invalidFields) => errorMessages.missingOrInvalidFields(invalidFields, invalidFields.map(name => this.schema.getField(name) ?? undefined)) 352 | ); 353 | } 354 | 355 | const setItem = await this.#store.setItem(String(item[this.idKeyName]), item); 356 | this.#broadcast(EventType.CREATED, setItem); 357 | 358 | return setItem; 359 | } catch (error: any) { 360 | this.#broadcast(EventType.ERROR, this.#createEventData(EventType.CREATED, data, null, error)); 361 | throw error; 362 | } 363 | }) 364 | } 365 | 366 | /** 367 | * updates a single item in the store if it exists 368 | * @param id 369 | * @param data 370 | * @return item | null 371 | */ 372 | async updateItem(id: string, data: Partial): Promise & SchemaDefaultValues> { 373 | return this.#trackProcess(EventType.UPDATED, async () => { 374 | try { 375 | if (!isValidObjectLiteral(data)) { 376 | throw new Error(errorMessages.invalidValueProvided("update", data)) 377 | } 378 | 379 | let existingItem = deepClone(await this.getItem(id) as T); 380 | 381 | if (existingItem) { 382 | let {item, id} = await this.#mergeExistingItemWithNewData(data, existingItem); 383 | 384 | this.#validateData( 385 | item, 386 | (invalidFields) => errorMessages.missingOrInvalidFields(invalidFields, invalidFields.map(name => this.schema.getField(name) ?? undefined)) 387 | ); 388 | 389 | const result = await this.#getWithResult(EventType.UPDATED, item, id); 390 | 391 | if (result === null) { 392 | this.#broadcast(EventType.ABORTED, { 393 | action: EventType.UPDATED, 394 | data 395 | }); 396 | 397 | return null; 398 | } 399 | 400 | if (isObjectLiteral(result)) { 401 | item = deepClone(result as T); 402 | 403 | this.#validateData( 404 | item, 405 | (invalidFields) => errorMessages.missingOrInvalidFields(invalidFields, invalidFields.map(name => this.schema.getField(name) ?? undefined)) 406 | ); 407 | } 408 | 409 | const setItem = await this.#store.setItem(String(id), item); 410 | this.#broadcast(EventType.UPDATED, setItem); 411 | return setItem; 412 | } 413 | } catch (error: any) { 414 | this.#broadcast(EventType.ERROR, this.#createEventData(EventType.UPDATED, data, id, error)); 415 | throw error; 416 | } 417 | 418 | return null; 419 | }) 420 | } 421 | 422 | /** 423 | * get a list of all items in the store 424 | * @return Array 425 | */ 426 | async getItems(): Promise>> { 427 | return this.findItems(() => true); 428 | } 429 | 430 | /** 431 | * get a single item in the store 432 | * @param id 433 | * @return item 434 | */ 435 | async getItem(id: string): Promise | null> { 436 | return this.#store.getItem(String(id)); 437 | } 438 | 439 | /** 440 | * removes a single item from the store if it exists 441 | * @param id 442 | * @return id | null 443 | */ 444 | async removeItem(id: string): Promise { 445 | return this.#trackProcess(EventType.REMOVED, async () => { 446 | try { 447 | let item = await this.getItem(id) as T; 448 | 449 | if (item) { 450 | const result = await this.#getWithResult(EventType.REMOVED, id, id); 451 | 452 | if (result === null) { 453 | this.#broadcast(EventType.ABORTED, { 454 | action: EventType.REMOVED, 455 | data: id 456 | }); 457 | } else { 458 | await this.#store.removeItem(`${id}`); 459 | this.#broadcast(EventType.REMOVED, id); 460 | return id; 461 | } 462 | } 463 | } catch (error: any) { 464 | this.#broadcast(EventType.ERROR, this.#createEventData(EventType.REMOVED, id, id, error)); 465 | throw error; 466 | } 467 | 468 | return null; 469 | }) 470 | } 471 | 472 | /** 473 | * clear the store from all its items 474 | * @return Array | null 475 | */ 476 | async clear(): Promise { 477 | return this.#trackProcess(EventType.CLEARED, async () => { 478 | const keys: string[] = (await this.#store.keys()); 479 | 480 | try { 481 | const result = await this.#getWithResult(EventType.CLEARED, keys); 482 | 483 | if (result === null) { 484 | this.#broadcast(EventType.ABORTED, { 485 | action: EventType.CLEARED, 486 | data: keys 487 | }); 488 | } else { 489 | await this.#store.clear(); 490 | this.#broadcast(EventType.CLEARED, keys); 491 | return keys; 492 | } 493 | } catch (error: any) { 494 | this.#broadcast(EventType.ERROR, this.#createEventData(EventType.CLEARED, keys, null, error)); 495 | throw error; 496 | } 497 | 498 | return null; 499 | }) 500 | } 501 | 502 | /** 503 | * find a single item in the store 504 | * @param cb 505 | * @return item 506 | */ 507 | async findItem(cb?: (value: Required, key: string) => boolean): Promise { 508 | if (typeof cb !== "function") { 509 | return null 510 | } 511 | 512 | return await this.#store.iterate, any>((value, key) => { 513 | const matched = cb(value, key); 514 | if (matched) { 515 | return value; 516 | } 517 | }) ?? null; 518 | } 519 | 520 | /** 521 | * find multiple items in the store 522 | * @param cb 523 | * @return Array 524 | */ 525 | async findItems(cb?: (value: Required, key: string) => boolean): Promise>> { 526 | if (typeof cb !== "function") { 527 | return [] 528 | } 529 | 530 | const items: Required[] = []; 531 | 532 | await this.#store.iterate, any>((value, key) => { 533 | if (cb(value, key)) { 534 | value && items.push(value) 535 | } 536 | }) 537 | 538 | return items; 539 | } 540 | 541 | async #mergeExistingItemWithNewData(data: Partial, existingItem: T | null = null): Promise<{item: T, id: string}> { 542 | const {defaultKeys, key, createKey, updateKey} = this.#getDefaultKeys(); 543 | const now = new Date(); 544 | const itemId = data[key]; 545 | let item: T = existingItem ?? await this.getItem(itemId as string) as T; 546 | 547 | if (item) { 548 | item = deepClone(item) as T; 549 | // @ts-ignore 550 | item[updateKey] = data[updateKey] ?? now; 551 | } else { 552 | item = this.schema.toValue(); 553 | // @ts-ignore 554 | item[key] = itemId ?? (new SchemaId()).defaultValue; 555 | // @ts-ignore 556 | item[createKey] = data[createKey] ?? now; 557 | // @ts-ignore 558 | item[updateKey] = item[createKey]; 559 | } 560 | 561 | for (const itemKey in data) { 562 | if (!defaultKeys.has(itemKey) && data.hasOwnProperty(itemKey)) { 563 | // @ts-ignore 564 | item[itemKey] = data[itemKey]; 565 | } 566 | } 567 | 568 | return { 569 | item, 570 | id: item[key] as string 571 | }; 572 | } 573 | 574 | #validateData(data: Partial | T, getErr: (invalidFields: string[]) => string) { 575 | const {defaultKeys} = this.#getDefaultKeys(); 576 | const invalidFields = this.schema.getInvalidSchemaDataFields(data as Record, defaultKeys); 577 | 578 | if (invalidFields.length) { 579 | throw new Error(getErr(invalidFields)) 580 | } 581 | } 582 | 583 | #broadcast(eventType: EventType, data: any) { 584 | this.#subscribers.forEach(sub => sub(eventType, data)); 585 | this.#eventHandlers.get(eventType)?.forEach(handler => handler(data)); 586 | } 587 | 588 | #createEventData(action: EventType, data: EventData, id: string | null = null, error: Error | null = null): ActionEventData> { 589 | return { 590 | action, 591 | data, 592 | id, 593 | error 594 | } 595 | } 596 | 597 | #getWithResult(event: EventType, data: EventData, id: string | null = null) { 598 | const eventData = { data, id }; 599 | const withHandler = this.#interceptEventHandlers.get(event); 600 | 601 | if(typeof withHandler === "function") { 602 | return withHandler(eventData); 603 | } 604 | 605 | if (this.#beforeChangeHandler) { 606 | return this.#beforeChangeHandler(event, eventData); 607 | } 608 | } 609 | 610 | #getDefaultKeys() { 611 | const key = this.idKeyName; 612 | const createKey = this.createdDateKeyName; 613 | const updateKey = this.updatedDateKeyName; 614 | 615 | return { 616 | key, 617 | createKey, 618 | updateKey, 619 | defaultKeys: new Set([key, createKey, updateKey]) as Set 620 | } 621 | } 622 | 623 | async #trackProcess(type: EventType, cb: () => any) { 624 | const processId = `${type}_${Date.now()}`; 625 | this.#processes.add(processId); 626 | 627 | if (this.#processes.size === 1) { // should contain only the newly added one 628 | this.#broadcast(EventType.PROCESSING, true); 629 | this.#broadcast(EventType.PROCESSING_EVENTS, this.processingEvents); 630 | } 631 | 632 | try { 633 | return await cb(); 634 | } catch (e) { 635 | throw e; 636 | } finally { 637 | this.#processes.delete(processId); 638 | 639 | if (!this.processing) { 640 | this.#broadcast(EventType.PROCESSING, false); 641 | this.#broadcast(EventType.PROCESSING_EVENTS, this.processingEvents); 642 | } 643 | } 644 | } 645 | } 646 | -------------------------------------------------------------------------------- /src/CustomTypes/ArrayOf.ts: -------------------------------------------------------------------------------- 1 | import {SchemaObjectLiteral, SchemaValueConstructorType} from "../types"; 2 | import {Schema} from "../Schema"; 3 | import {CustomType} from "./CustomType"; 4 | import {isObjectLiteral} from "../utils/is-object-literal"; 5 | import {objectToSchema} from "../utils/object-to-schema"; 6 | 7 | export function ArrayOf(type: SchemaObjectLiteral | SchemaValueConstructorType | Schema) { 8 | const typeName = CustomType.getTypeName(type); 9 | 10 | if (isObjectLiteral(type)) { 11 | type = objectToSchema(typeName, type as SchemaObjectLiteral); 12 | } 13 | 14 | const name = `Array<${type instanceof Schema 15 | ? (typeName ? `Schema<${typeName}>` : "Schema") 16 | : typeName}>`; 17 | 18 | const CustomTypeConstructor = class extends CustomType { 19 | constructor() { 20 | super(name, type, []); 21 | } 22 | } 23 | 24 | Object.defineProperty (CustomTypeConstructor, 'name', {value: 'ArrayOf'}); 25 | 26 | return CustomTypeConstructor; 27 | } 28 | -------------------------------------------------------------------------------- /src/CustomTypes/CustomType.ts: -------------------------------------------------------------------------------- 1 | import {SchemaObjectLiteral, SchemaValueConstructorType, SchemaValueType} from "../types"; 2 | import {Schema} from "../Schema"; 3 | import {isObjectLiteral} from "../utils/is-object-literal"; 4 | 5 | export class CustomType { 6 | constructor( 7 | public name: string, 8 | public type: SchemaObjectLiteral | SchemaValueConstructorType | Schema | Array> | null, 9 | public defaultValue: SchemaValueType, 10 | ) { 11 | } 12 | static getTypeName(type: SchemaObjectLiteral | SchemaValueConstructorType | Schema) { 13 | if (isObjectLiteral(type)) { 14 | return ""; 15 | } 16 | 17 | let typeInstance: any; 18 | const isSchema = type instanceof Schema; 19 | 20 | try { 21 | if (isSchema) { 22 | typeInstance = type; 23 | } else { 24 | // @ts-ignore 25 | typeInstance = new type(); 26 | 27 | if (!(typeInstance instanceof CustomType)) { 28 | typeInstance = type; 29 | } 30 | } 31 | } catch(e) {} 32 | 33 | return typeInstance.name; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/CustomTypes/Null.ts: -------------------------------------------------------------------------------- 1 | import {CustomType} from "./CustomType"; 2 | 3 | export const Null = class extends CustomType { 4 | constructor() { 5 | super('Null', null, null); 6 | } 7 | } 8 | 9 | Object.defineProperty(Null, 'name', {value: 'Null'}) 10 | -------------------------------------------------------------------------------- /src/CustomTypes/OneOf.spec.ts: -------------------------------------------------------------------------------- 1 | import {OneOf} from "./OneOf"; 2 | import {SchemaObjectLiteral, SchemaValueConstructorType} from "../types"; 3 | import {Schema} from "../Schema"; 4 | import {ArrayOf} from "./ArrayOf"; 5 | import {SchemaValue} from "../SchemaValue"; 6 | import {Null} from "./Null"; 7 | 8 | describe('OneOf', () => { 9 | const getOneOfInstance = (types: Array>, def: any) => { 10 | // @ts-ignore 11 | return new (OneOf(types, def))() 12 | } 13 | 14 | it('should have to correct name', () => { 15 | expect(getOneOfInstance([Number, Null], null).name).toBe('Number | Null'); 16 | expect(getOneOfInstance([String, Number], "").name).toBe('String | Number'); 17 | expect(getOneOfInstance([String, Boolean], false).name).toBe('String | Boolean'); 18 | expect(getOneOfInstance([Date, Boolean], false).name).toBe('Date | Boolean'); 19 | expect(getOneOfInstance([Array, String], "").name).toBe('Array | String'); 20 | expect(getOneOfInstance([ArrayOf(String), ArrayOf(Number)], []).name).toBe('Array | Array'); 21 | expect(getOneOfInstance([ArrayOf(OneOf([String, Number], "")), ArrayOf(OneOf([String, Boolean], ""))], []).name).toBe('Array | Array'); 22 | expect(getOneOfInstance([ArrayOf(OneOf([String, Number], "")), ArrayOf(OneOf([String, Boolean], ""))], []).name).toBe('Array | Array'); 23 | expect(getOneOfInstance([ArrayOf(OneOf([Date, Boolean], false)), Number], []).name).toBe('Array | Number'); 24 | expect(getOneOfInstance([Float32Array, Float64Array], new Float32Array()).name).toBe('Float32Array | Float64Array'); 25 | expect(getOneOfInstance([Int8Array, Int16Array, Int32Array], new Int8Array()).name).toBe('Int8Array | Int16Array | Int32Array'); 26 | expect(getOneOfInstance([Uint8Array, Uint16Array, Uint32Array], new Uint8Array()).name).toBe('Uint8Array | Uint16Array | Uint32Array'); 27 | expect(getOneOfInstance([new Schema("test", {name: new SchemaValue(String)}), Array], {}).name).toBe('Schema | Array'); 28 | expect(getOneOfInstance([{ 29 | $name: String 30 | }, Array], []).name).toBe('Schema | Array'); 31 | }); 32 | 33 | it('should throw error if only one type is provided', () => { 34 | expect(() => getOneOfInstance([String], "")).toThrowError('OneOf requires more than single type listed comma separated'); 35 | }); 36 | 37 | it('should throw error if provided a OneOf', () => { 38 | expect(() => getOneOfInstance([OneOf([Date, Boolean], false), Number], 12)).toThrowError('Cannot nest "OneOf" types'); 39 | }); 40 | }) 41 | -------------------------------------------------------------------------------- /src/CustomTypes/OneOf.ts: -------------------------------------------------------------------------------- 1 | import {SchemaObjectLiteral, SchemaValueConstructorType} from "../types"; 2 | import {Schema} from "../Schema"; 3 | import {CustomType} from "./CustomType"; 4 | import {isObjectLiteral} from "../utils/is-object-literal"; 5 | import {objectToSchema} from "../utils/object-to-schema"; 6 | import {isSameValueType} from "../utils/is-same-value-type"; 7 | 8 | export function OneOf(types: Array>, defaultValue: any): typeof CustomType { 9 | if (types.length < 2) { 10 | throw new Error('OneOf requires more than single type listed comma separated') 11 | } 12 | 13 | types = types.map(t => { 14 | if (isObjectLiteral(t)) { 15 | return objectToSchema("", t as SchemaObjectLiteral) 16 | } 17 | 18 | return t; 19 | }) 20 | 21 | if (!types.some(t => isSameValueType(t as SchemaValueConstructorType, defaultValue))) { 22 | throw new Error(`Default value specified in OneOf is not of matching type.`) 23 | } 24 | 25 | const name = types 26 | .map(t => { 27 | if (t.name === 'OneOf') { 28 | throw new Error('Cannot nest "OneOf" types'); 29 | } 30 | 31 | const tName = CustomType.getTypeName(t); 32 | 33 | if (t instanceof Schema) { 34 | return tName ? `Schema<${tName}>` : 'Schema' 35 | } 36 | 37 | return tName; 38 | }) 39 | .join(' | '); 40 | 41 | const CustomTypeConstructor = class extends CustomType { 42 | constructor() { 43 | super(name, types, defaultValue); 44 | } 45 | } 46 | 47 | Object.defineProperty(CustomTypeConstructor, 'name', {value: 'OneOf'}); 48 | 49 | return CustomTypeConstructor; 50 | } 51 | -------------------------------------------------------------------------------- /src/CustomTypes/SchemaId.ts: -------------------------------------------------------------------------------- 1 | import {generateUUID} from "../utils/generate-uuid"; 2 | import {CustomType} from "./CustomType"; 3 | 4 | export class SchemaId extends CustomType { 5 | constructor() { 6 | super('SchemaId', String, (() => { 7 | try { 8 | return crypto.randomUUID(); 9 | } catch (e) { 10 | return generateUUID(); 11 | } 12 | })()); 13 | } 14 | } -------------------------------------------------------------------------------- /src/MemoryStore.spec.ts: -------------------------------------------------------------------------------- 1 | import {MemoryStore} from "./MemoryStore"; 2 | 3 | describe("MemoryStore" , () => { 4 | const config = { 5 | storeName: "Test", 6 | name: "app", 7 | version: 1 8 | }; 9 | 10 | it('should CRUD', async () => { 11 | const store = MemoryStore(); 12 | 13 | store._initStorage(config); 14 | 15 | await store.setItem("1", "One"); 16 | 17 | await expect(store.getItem("1")).resolves.toEqual("One") 18 | await expect(store.length()).resolves.toEqual(1) 19 | 20 | await store.setItem("2", "Two"); 21 | 22 | await expect(store.length()).resolves.toEqual(2) 23 | 24 | await expect(store.key(1)).resolves.toEqual("2") 25 | await expect(store.keys()).resolves.toEqual(["1", "2"]) 26 | 27 | const iterator = jest.fn(); 28 | 29 | await store.iterate(iterator); 30 | 31 | expect(iterator).toHaveBeenCalledWith("One", "1", 0) 32 | expect(iterator).toHaveBeenCalledWith("Two", "2", 1) 33 | 34 | await expect(store.iterate((val: any, key: string) => { 35 | if (key === "2") { 36 | return val; 37 | } 38 | })).resolves.toEqual("Two") 39 | 40 | await store.removeItem("2") 41 | 42 | await expect(store.length()).resolves.toEqual(1) 43 | await expect(store.keys()).resolves.toEqual(["1"]) 44 | 45 | await store.clear() 46 | 47 | await expect(store.length()).resolves.toEqual(0) 48 | await expect(store.keys()).resolves.toEqual([]) 49 | 50 | // @ts-ignore 51 | await store.dropInstance(); 52 | }); 53 | }) 54 | -------------------------------------------------------------------------------- /src/MemoryStore.ts: -------------------------------------------------------------------------------- 1 | type errCallback = (err: any) => void; 2 | type successCallback = (err: any, value: T) => void 3 | type iteratee = (value: T, key: string, iterationNumber: number) => U; 4 | 5 | const callBackOrPromise = (value: T, cb?: errCallback | successCallback) => { 6 | return typeof cb === "function" ? cb(null, value) : Promise.resolve(value); 7 | } 8 | 9 | export const MEMORYSTORAGE = "Memory"; 10 | 11 | const maps: Record> = {}; 12 | 13 | const getMapKeyFromConfig = (config: LocalForageOptions) => `${config.storeName}-${config.name}-${config.version}`; 14 | const getMap = ({_config}: any) => { 15 | return maps[getMapKeyFromConfig(_config)] ?? {} 16 | } 17 | 18 | export const MemoryStore = (): LocalForageDriver => { 19 | 20 | return { 21 | _driver: MEMORYSTORAGE, 22 | _initStorage(config) { 23 | // @ts-ignore 24 | this._config = config; 25 | maps[getMapKeyFromConfig(config)] = new Map(); 26 | }, 27 | async clear(cb: errCallback) { 28 | getMap(this).clear(); 29 | 30 | callBackOrPromise([], cb); 31 | }, 32 | async getItem(id: string, cb?: successCallback) { 33 | const item = getMap(this).get(id); 34 | 35 | return callBackOrPromise(item ?? null, cb) || null; 36 | }, 37 | // @ts-ignore 38 | async iterate(cb: iteratee, onErr?: successCallback) { 39 | let i = 0; 40 | let res: U; 41 | 42 | for (let [key, value] of getMap(this).entries()) { 43 | res = cb(value, key, i); 44 | if (res !== undefined) { 45 | return callBackOrPromise(res, onErr); 46 | } 47 | 48 | i += 1; 49 | } 50 | 51 | return callBackOrPromise(null, onErr); 52 | }, 53 | async key(key: number, cb?: successCallback) { 54 | const keys = Array.from(getMap(this).keys()); 55 | 56 | return callBackOrPromise(keys[key], cb) || ""; 57 | }, 58 | async keys(cb?: successCallback) { 59 | const keys = Array.from(getMap(this).keys()); 60 | 61 | return callBackOrPromise(keys, cb) || []; 62 | }, 63 | async length(cb?: successCallback) { 64 | return callBackOrPromise(getMap(this).size, cb) || 0; 65 | }, 66 | async removeItem(id: string, cb?: successCallback) { 67 | getMap(this).delete(id) 68 | 69 | return callBackOrPromise(id, cb); 70 | }, 71 | async setItem(id: any, value: any, cb?: successCallback) { 72 | getMap(this).set(id, value) 73 | 74 | return callBackOrPromise(value, cb); 75 | }, 76 | async dropInstance() { 77 | // @ts-ignore 78 | delete maps[getMapKeyFromConfig(this._config)] 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/Schema.spec.ts: -------------------------------------------------------------------------------- 1 | import {Schema} from "./Schema"; 2 | import {SchemaDefaultValues} from "./types"; 3 | import {SchemaId} from "./CustomTypes/SchemaId"; 4 | import {SchemaValue} from "./SchemaValue"; 5 | import {ArrayOf} from "./CustomTypes/ArrayOf"; 6 | import {OneOf} from "./CustomTypes/OneOf"; 7 | import {Null} from "./CustomTypes/Null"; 8 | 9 | describe('Schema', () => { 10 | it('should fail if obj is invalid', () => { 11 | expect(() => new Schema('sample', { 12 | // @ts-ignore 13 | val: Symbol('invalid') 14 | })).toThrowError('Field "val" is not a SchemaValue') 15 | 16 | expect(() => new Schema('sample', { 17 | // @ts-ignore 18 | fn: Function 19 | })).toThrowError('Field "fn" is not a SchemaValue') 20 | 21 | expect(() => new Schema('sample', { 22 | // @ts-ignore 23 | fn: new SchemaValue(Function) 24 | })).toThrowError('Invalid SchemaValue type provided. Received "Function"') 25 | }); 26 | 27 | describe('should handle simple types', () => { 28 | interface ToDo { 29 | name: string; 30 | description: string; 31 | userId: number; 32 | selected: boolean; 33 | state: string; 34 | } 35 | 36 | let todoSchema: Schema; 37 | 38 | beforeEach(() => { 39 | todoSchema = new Schema("todo"); 40 | 41 | todoSchema.defineField("name", String, {required: true}); 42 | todoSchema.defineField("description", String); 43 | todoSchema.defineField("userId", SchemaId, {required: true}); 44 | todoSchema.defineField("selected", Boolean); 45 | todoSchema.defineField("state", String); 46 | }) 47 | 48 | it('should get the name', () => { 49 | expect(todoSchema.name).toBe('todo') 50 | }); 51 | 52 | it('should return correct JSON value', () => { 53 | expect(todoSchema.toJSON()).toEqual({ 54 | "description": { 55 | "defaultValue": "", 56 | "required": false, 57 | "type": "String" 58 | }, 59 | "name": { 60 | "defaultValue": "", 61 | "required": true, 62 | "type": "String" 63 | }, 64 | "selected": { 65 | "defaultValue": false, 66 | "required": false, 67 | "type": "Boolean" 68 | }, 69 | "state": { 70 | "defaultValue": "", 71 | "required": false, 72 | "type": "String" 73 | }, 74 | "userId": { 75 | "defaultValue": expect.any(String), 76 | "required": true, 77 | "type": "SchemaId" 78 | } 79 | }); 80 | }); 81 | 82 | it('should return correct value', () => { 83 | expect(todoSchema.toValue()).toEqual({ 84 | "description": "", 85 | "name": "", 86 | "selected": false, 87 | "state": "", 88 | "userId": expect.any(String) 89 | }) 90 | }); 91 | 92 | it('should remove field', () => { 93 | todoSchema.removeField("selected"); 94 | 95 | expect(todoSchema.getField("selected")).toBeNull() 96 | }); 97 | 98 | it('should check field', () => { 99 | expect(todoSchema.hasField("description")).toBeTruthy() 100 | expect(todoSchema.getField("description")).toEqual( new SchemaValue(String)) 101 | // @ts-ignore 102 | expect(todoSchema.hasField("deletedDae")).toBeFalsy() 103 | expect(todoSchema.getField("deletedDae")).toBe(null) 104 | expect(todoSchema.getField("")).toBe(null) 105 | }); 106 | 107 | it('should update field', () => { 108 | expect(todoSchema.getField("state")).toEqual({"defaultValue": "", "required": false, "type": String}); 109 | 110 | todoSchema.defineField("state", Number, {defaultValue: 5}); 111 | 112 | expect(todoSchema.getField("state")).toEqual({"defaultValue": 5, "required": false, "type": Number}) 113 | }); 114 | 115 | it('should check for valid field value', () => { 116 | todoSchema.defineField("state", Number, {defaultValue: 5}); 117 | 118 | expect(todoSchema.isValidFieldValue("state", 120)).toBeTruthy() 119 | expect(todoSchema.isValidFieldValue("state", "sample")).toBeFalsy() 120 | expect(todoSchema.isValidFieldValue("state")).toBeFalsy() 121 | 122 | expect(todoSchema.isValidFieldValue("name", "sample")).toBeTruthy() 123 | expect(todoSchema.isValidFieldValue("name", 120)).toBeFalsy() 124 | expect(todoSchema.isValidFieldValue("name")).toBeFalsy() 125 | 126 | expect(todoSchema.isValidFieldValue("selected", true)).toBeTruthy() 127 | expect(todoSchema.isValidFieldValue("selected", "sample")).toBeFalsy() 128 | expect(todoSchema.isValidFieldValue("selected")).toBeFalsy() 129 | 130 | expect(todoSchema.isValidFieldValue("userId", SchemaId)).toBeFalsy() 131 | expect(todoSchema.isValidFieldValue("userId", new SchemaId())).toBeTruthy() 132 | expect(todoSchema.isValidFieldValue("userId", (new SchemaId()).defaultValue)).toBeTruthy() 133 | expect(todoSchema.isValidFieldValue("userId", "sample")).toBeFalsy() 134 | expect(todoSchema.isValidFieldValue("userId")).toBeFalsy() 135 | }); 136 | 137 | it('should check if data matches schema', () => { 138 | expect(todoSchema.getInvalidSchemaDataFields({ 139 | name: "My todo" 140 | })).toEqual(["userId"]) 141 | expect(todoSchema.getInvalidSchemaDataFields({ 142 | name: "My todo", 143 | userId: new SchemaId() 144 | })).toEqual([]) 145 | }); 146 | }); 147 | 148 | describe("should handle complex types", () => { 149 | it('Blob', () => { 150 | interface DT extends SchemaDefaultValues { 151 | data: Blob; 152 | } 153 | 154 | const blob = new Blob(['hey!'], {type: 'text/html'}); 155 | 156 | const blobSchema = new Schema
("blob", { 157 | data: new SchemaValue(Blob) 158 | }) 159 | 160 | expect(blobSchema.toJSON()).toEqual({ 161 | "data": { 162 | "defaultValue": null, 163 | "required": false, 164 | "type": "Blob" 165 | }, 166 | }) 167 | expect(blobSchema.isValidFieldValue("data", blob)).toBeTruthy() 168 | expect(blobSchema.isValidFieldValue("data", 12)).toBeFalsy() 169 | expect(blobSchema.getInvalidSchemaDataFields({ 170 | data: 12, 171 | new: "yes" 172 | })).toEqual(["data", "new"]) 173 | expect(blobSchema.getInvalidSchemaDataFields({ 174 | data: blob, 175 | new: "yes" 176 | })).toEqual(["new"]) 177 | expect(blobSchema.getInvalidSchemaDataFields({ 178 | data: blob, 179 | })).toEqual([]) 180 | }); 181 | 182 | it('Array', () => { 183 | interface DT extends SchemaDefaultValues { 184 | data: []; 185 | } 186 | 187 | const arraySchema = new Schema
("array", { 188 | data: new SchemaValue(Array) 189 | }) 190 | 191 | expect(arraySchema.toJSON()).toEqual({ 192 | "data": { 193 | "defaultValue": [], 194 | "required": false, 195 | "type": "Array" 196 | }, 197 | }) 198 | expect(arraySchema.isValidFieldValue("data", [12, true, new SchemaId()])).toBeTruthy() 199 | expect(arraySchema.isValidFieldValue("data", [12, {}, "sample"])).toBeTruthy() 200 | expect(arraySchema.isValidFieldValue("data", true)).toBeFalsy() 201 | expect(arraySchema.getInvalidSchemaDataFields({ 202 | data: 12, 203 | new: "yes" 204 | })).toEqual(["data", "new"]) 205 | expect(arraySchema.getInvalidSchemaDataFields({ 206 | data: [12, true, {}], 207 | obj: "yes" 208 | })).toEqual(["obj"]) 209 | expect(arraySchema.getInvalidSchemaDataFields({ 210 | data: [12, true], 211 | })).toEqual([]) 212 | }); 213 | 214 | it('ArrayOf', () => { 215 | let arraySchema = new Schema("array", { 216 | data: new SchemaValue(ArrayOf(Number), true) 217 | }) 218 | 219 | expect(arraySchema.toJSON()).toEqual({ 220 | "data": { 221 | "defaultValue": [], 222 | "required": true, 223 | "type": "Array" 224 | }, 225 | }) 226 | expect(arraySchema.isValidFieldValue("data", [12])).toBeTruthy() 227 | expect(arraySchema.isValidFieldValue("data", [new Number(99), 33])).toBeTruthy() 228 | expect(arraySchema.isValidFieldValue("data", [12, 34, 66])).toBeTruthy() 229 | expect(arraySchema.isValidFieldValue("data", [12, true, new SchemaId()])).toBeFalsy() 230 | expect(arraySchema.isValidFieldValue("data", [12, {}, "sample"])).toBeFalsy() 231 | expect(arraySchema.isValidFieldValue("data", true)).toBeFalsy() 232 | expect(arraySchema.getInvalidSchemaDataFields({ 233 | new: "yes" 234 | })).toEqual(["new", "data"]) 235 | expect(arraySchema.getInvalidSchemaDataFields({ 236 | data: [12, true, {}], 237 | obj: "yes" 238 | })).toEqual(["data", "obj"]) 239 | expect(arraySchema.getInvalidSchemaDataFields({ 240 | data: [12, 88], 241 | })).toEqual([]) 242 | expect(arraySchema.getInvalidSchemaDataFields({ 243 | data: [], 244 | })).toEqual([]) 245 | 246 | const userSchema = new Schema("user", { 247 | name: new SchemaValue(String, true), 248 | avatar: new SchemaValue(String), 249 | }); 250 | 251 | arraySchema = new Schema("array", { 252 | data: new SchemaValue(ArrayOf(userSchema), true) 253 | }); 254 | 255 | expect(arraySchema.toJSON()).toEqual({ 256 | "data": { 257 | "defaultValue": [], 258 | "required": true, 259 | "type": "Array>" 260 | } 261 | }) 262 | 263 | expect(arraySchema.getInvalidSchemaDataFields({ 264 | data: [ 265 | {} 266 | ] 267 | })).toEqual(["data[0].name"]) 268 | expect(arraySchema.getInvalidSchemaDataFields({ 269 | data: [ 270 | { 271 | name: "john" 272 | }, 273 | 12, 274 | ] 275 | })).toEqual(["data[1]"]) 276 | }); 277 | 278 | it('ArrayOf object literal', () => { 279 | let arraySchema = new Schema("array", { 280 | data: new SchemaValue(ArrayOf({$name: String}), true) 281 | }) 282 | 283 | expect(arraySchema.toJSON()).toEqual({ 284 | "data": { 285 | "defaultValue": [], 286 | "required": true, 287 | "type": "Array" 288 | }, 289 | }) 290 | expect(arraySchema.isValidFieldValue("data", [12])).toBeFalsy() 291 | expect(arraySchema.isValidFieldValue("data", [{name: "john doe"}])).toBeTruthy() 292 | expect(arraySchema.isValidFieldValue("data", [{}])).toBeFalsy() 293 | expect(arraySchema.getInvalidSchemaDataFields({ 294 | new: "yes" 295 | })).toEqual(["new", "data"]) 296 | expect(arraySchema.getInvalidSchemaDataFields({ 297 | data: [12, true, {}, {name: "jd"}], 298 | obj: "yes" 299 | })).toEqual(["data[0]", "data[1]", "data[2].name", "obj"]) 300 | expect(arraySchema.getInvalidSchemaDataFields({ 301 | data: [], 302 | })).toEqual([]) 303 | 304 | const userSchema = new Schema("user", { 305 | name: new SchemaValue(String, true), 306 | avatar: new SchemaValue(String), 307 | }); 308 | 309 | arraySchema = new Schema("array", { 310 | data: new SchemaValue(ArrayOf(userSchema), true) 311 | }); 312 | 313 | expect(arraySchema.toJSON()).toEqual({ 314 | "data": { 315 | "defaultValue": [], 316 | "required": true, 317 | "type": "Array>" 318 | } 319 | }) 320 | 321 | expect(arraySchema.getInvalidSchemaDataFields({ 322 | data: [ 323 | {} 324 | ] 325 | })).toEqual(["data[0].name"]) 326 | expect(arraySchema.getInvalidSchemaDataFields({ 327 | data: [ 328 | { 329 | name: "john" 330 | }, 331 | 12, 332 | ] 333 | })).toEqual(["data[1]"]) 334 | }); 335 | 336 | it('OneOf', () => { 337 | let oneOfSchema = new Schema("array", { 338 | data: new SchemaValue(OneOf([Number, String, Null], null)) 339 | }) 340 | 341 | expect(oneOfSchema.toJSON()).toBeDefined() 342 | expect(oneOfSchema.toJSON()).toEqual({ 343 | "data": { 344 | "defaultValue": null, 345 | "required": false, 346 | "type": "Number | String | Null" 347 | }, 348 | }) 349 | expect(oneOfSchema.isValidFieldValue("data", [12])).toBeFalsy() 350 | expect(oneOfSchema.isValidFieldValue("data", new Number(99))).toBeTruthy() 351 | expect(oneOfSchema.isValidFieldValue("data", 34)).toBeTruthy() 352 | expect(oneOfSchema.isValidFieldValue("data", new String("sample"))).toBeTruthy() 353 | expect(oneOfSchema.isValidFieldValue("data", "sample")).toBeTruthy() 354 | expect(oneOfSchema.isValidFieldValue("data", null)).toBeTruthy() 355 | expect(oneOfSchema.getInvalidSchemaDataFields({ 356 | new: "yes" 357 | })).toEqual(["new"]) 358 | expect(oneOfSchema.getInvalidSchemaDataFields({ 359 | data: 12, 360 | obj: "yes" 361 | })).toEqual(["obj"]) 362 | expect(oneOfSchema.getInvalidSchemaDataFields({ 363 | data: "sample", 364 | })).toEqual([]) 365 | expect(oneOfSchema.getInvalidSchemaDataFields({ 366 | data: null, 367 | })).toEqual([]) 368 | 369 | const userSchema = new Schema("user", { 370 | name: new SchemaValue(String, true), 371 | avatar: new SchemaValue(String), 372 | }); 373 | 374 | oneOfSchema = new Schema("array", { 375 | data: new SchemaValue(OneOf([userSchema, String], ""), true) 376 | }); 377 | 378 | expect(oneOfSchema.toJSON()).toEqual({ 379 | "data": { 380 | "defaultValue": "", 381 | "required": true, 382 | "type": "Schema | String" 383 | } 384 | }) 385 | 386 | expect(oneOfSchema.getInvalidSchemaDataFields({ 387 | data: 23 388 | })).toEqual(["data"]) 389 | expect(oneOfSchema.getInvalidSchemaDataFields({ 390 | data: {} 391 | })).toEqual(["data.name"]) 392 | expect(oneOfSchema.getInvalidSchemaDataFields({ 393 | data: "sample" 394 | })).toEqual([]) 395 | expect(oneOfSchema.getInvalidSchemaDataFields({ 396 | data: { 397 | name: "john" 398 | } 399 | })).toEqual([]) 400 | }); 401 | 402 | it('OneOf object literal', () => { 403 | let oneOfSchema = new Schema("array", { 404 | data: new SchemaValue(OneOf([{$name: String}, String], "")) 405 | }) 406 | 407 | expect(oneOfSchema.toJSON()).toBeDefined() 408 | expect(oneOfSchema.toJSON()).toEqual({ 409 | "data": { 410 | "defaultValue": "", 411 | "required": false, 412 | "type": "Schema | String" 413 | }, 414 | }) 415 | expect(oneOfSchema.isValidFieldValue("data", [12])).toBeFalsy() 416 | expect(oneOfSchema.isValidFieldValue("data", "john doe")).toBeTruthy() 417 | expect(oneOfSchema.isValidFieldValue("data", {name: "john doe"})).toBeTruthy() 418 | expect(oneOfSchema.isValidFieldValue("data", new String("sample"))).toBeTruthy() 419 | expect(oneOfSchema.isValidFieldValue("data", "sample")).toBeTruthy() 420 | expect(oneOfSchema.getInvalidSchemaDataFields({ 421 | new: "yes" 422 | })).toEqual(["new"]) 423 | expect(oneOfSchema.getInvalidSchemaDataFields({ 424 | data: 12, 425 | obj: "yes" 426 | })).toEqual(["data", "obj"]) 427 | expect(oneOfSchema.getInvalidSchemaDataFields({ 428 | data: "sample", 429 | })).toEqual([]) 430 | expect(oneOfSchema.getInvalidSchemaDataFields({ 431 | data: null, 432 | })).toEqual(["data"]) 433 | 434 | const userSchema = new Schema("user", { 435 | name: new SchemaValue(String, true), 436 | avatar: new SchemaValue(String), 437 | }); 438 | 439 | oneOfSchema = new Schema("array", { 440 | data: new SchemaValue(OneOf([userSchema, String], ""), true) 441 | }); 442 | 443 | expect(oneOfSchema.toJSON()).toEqual({ 444 | "data": { 445 | "defaultValue": "", 446 | "required": true, 447 | "type": "Schema | String" 448 | } 449 | }) 450 | 451 | expect(oneOfSchema.getInvalidSchemaDataFields({ 452 | data: 23 453 | })).toEqual(["data"]) 454 | expect(oneOfSchema.getInvalidSchemaDataFields({ 455 | data: {} 456 | })).toEqual(["data.name"]) 457 | expect(oneOfSchema.getInvalidSchemaDataFields({ 458 | data: "sample" 459 | })).toEqual([]) 460 | expect(oneOfSchema.getInvalidSchemaDataFields({ 461 | data: { 462 | name: "john" 463 | } 464 | })).toEqual([]) 465 | }); 466 | 467 | it('ArrayBuffer', () => { 468 | interface DT extends SchemaDefaultValues { 469 | data: ArrayBuffer; 470 | } 471 | 472 | const arraySchema = new Schema
("arrayBuffer", { 473 | data: new SchemaValue(ArrayBuffer) 474 | }) 475 | 476 | expect(arraySchema.toJSON()).toEqual({ 477 | "data": { 478 | "defaultValue": null, 479 | "required": false, 480 | "type": "ArrayBuffer" 481 | }, 482 | }) 483 | expect(arraySchema.isValidFieldValue("data", new ArrayBuffer(10))).toBeTruthy() 484 | expect(arraySchema.isValidFieldValue("data", [12, {}, "sample"])).toBeFalsy() 485 | expect(arraySchema.isValidFieldValue("data", true)).toBeFalsy() 486 | expect(arraySchema.getInvalidSchemaDataFields({ 487 | data: 12, 488 | new: "yes" 489 | })).toEqual(["data", "new"]) 490 | expect(arraySchema.getInvalidSchemaDataFields({ 491 | data: [12, true, {}], 492 | obj: "yes" 493 | })).toEqual(["data", "obj"]) 494 | expect(arraySchema.getInvalidSchemaDataFields({ 495 | data: new ArrayBuffer(0), 496 | })).toEqual([]) 497 | }); 498 | 499 | it('Int32Array', () => { 500 | interface DT extends SchemaDefaultValues { 501 | data: Int32Array; 502 | } 503 | 504 | const in32Array = new Int32Array([12, 45]); 505 | 506 | const in32ArraySchema = new Schema
("in32Array", { 507 | data: new SchemaValue(Int32Array) 508 | }) 509 | 510 | expect(in32ArraySchema.toJSON()).toEqual({ 511 | "data": { 512 | "defaultValue": expect.any(Int32Array), 513 | "required": false, 514 | "type": "Int32Array" 515 | }, 516 | }) 517 | expect(in32ArraySchema.isValidFieldValue("data", in32Array)).toBeTruthy() 518 | expect(in32ArraySchema.isValidFieldValue("data", "sample")).toBeFalsy() 519 | expect(in32ArraySchema.getInvalidSchemaDataFields({ 520 | data: 12, 521 | new: "yes" 522 | })).toEqual(["data", "new"]) 523 | expect(in32ArraySchema.getInvalidSchemaDataFields({ 524 | data: in32Array, 525 | new: "yes" 526 | })).toEqual(["new"]) 527 | expect(in32ArraySchema.getInvalidSchemaDataFields({ 528 | data: in32Array, 529 | })).toEqual([]) 530 | }); 531 | 532 | it('Null', () => { 533 | interface DT extends SchemaDefaultValues { 534 | data: null; 535 | } 536 | 537 | const schema = new Schema
("null", { 538 | data: new SchemaValue(Null) 539 | }) 540 | 541 | expect(schema.toJSON()).toEqual({ 542 | "data": { 543 | "defaultValue": null, 544 | "required": false, 545 | "type": "Null" 546 | }, 547 | }) 548 | expect(schema.isValidFieldValue("data", null)).toBeTruthy() 549 | expect(schema.isValidFieldValue("data", "sample")).toBeFalsy() 550 | expect(schema.getInvalidSchemaDataFields({ 551 | data: 12, 552 | new: "yes" 553 | })).toEqual(["data", "new"]) 554 | expect(schema.getInvalidSchemaDataFields({ 555 | data: null, 556 | new: "yes" 557 | })).toEqual(["new"]) 558 | expect(schema.getInvalidSchemaDataFields({ 559 | data: null, 560 | })).toEqual([]) 561 | }); 562 | }) 563 | 564 | describe('should handle no default values schema', () => { 565 | interface ParkingTicket { 566 | id: SchemaId; 567 | createdDate: Date; 568 | arrivalTime: Date; 569 | departureTime: Date; 570 | } 571 | 572 | let parkingTicketSchema: Schema; 573 | 574 | beforeEach(() => { 575 | parkingTicketSchema = new Schema("parkingTicket", null); 576 | 577 | parkingTicketSchema.defineField("id", SchemaId, {required: true}); 578 | parkingTicketSchema.defineField("createdDate", Date); 579 | parkingTicketSchema.defineField("arrivalTime", Date); 580 | parkingTicketSchema.defineField("departureTime", Date, {defaultValue: undefined}); 581 | }) 582 | 583 | it('should return correct JSON value', () => { 584 | expect(parkingTicketSchema.toJSON()).toEqual({ 585 | "arrivalTime": { 586 | "defaultValue": null, 587 | "required": false, 588 | "type": "Date" 589 | }, 590 | "createdDate": { 591 | "defaultValue": null, 592 | "required": false, 593 | "type": "Date" 594 | }, 595 | "departureTime": { 596 | "defaultValue": null, 597 | "required": false, 598 | "type": "Date" 599 | }, 600 | "id": { 601 | "defaultValue": expect.any(String), 602 | "required": true, 603 | "type": "SchemaId" 604 | } 605 | }); 606 | }); 607 | 608 | it('should return correct value', () => { 609 | expect(parkingTicketSchema.toValue()).toEqual(expect.objectContaining({ 610 | "arrivalTime": expect.any(Date), 611 | "departureTime": expect.any(Date), 612 | "createdDate": expect.any(Date), 613 | "id": expect.any(String) 614 | })) 615 | }); 616 | }); 617 | 618 | it('should handle nested schemas', () => { 619 | interface ToDo extends SchemaDefaultValues { 620 | name: string; 621 | description: string; 622 | user: { 623 | name: string; 624 | avatar: string; 625 | }; 626 | selected: boolean; 627 | state: string; 628 | } 629 | 630 | const userSchema = new Schema("user", { 631 | name: new SchemaValue(String, true), 632 | avatar: new SchemaValue(String), 633 | }); 634 | const todoSchema = new Schema("todo", { 635 | name: new SchemaValue(String, true), 636 | description: new SchemaValue(String), 637 | user: new SchemaValue(userSchema, true), 638 | selected: new SchemaValue(Boolean), 639 | state: new SchemaValue(String), 640 | }); 641 | 642 | expect(todoSchema.toJSON()).toEqual({ 643 | "description": { 644 | "defaultValue": "", 645 | "required": false, 646 | "type": "String" 647 | }, 648 | "name": { 649 | "defaultValue": "", 650 | "required": true, 651 | "type": "String" 652 | }, 653 | "selected": { 654 | "defaultValue": false, 655 | "required": false, 656 | "type": "Boolean" 657 | }, 658 | "state": { 659 | "defaultValue": "", 660 | "required": false, 661 | "type": "String" 662 | }, 663 | "user": { 664 | "defaultValue": { 665 | "avatar": "", 666 | "name": "" 667 | }, 668 | "required": true, 669 | "type": "Schema" 670 | } 671 | }) 672 | expect(todoSchema.toValue()).toEqual({ 673 | "description": "", 674 | "name": "", 675 | "selected": false, 676 | "state": "", 677 | "user": { 678 | "avatar": "", 679 | "name": "" 680 | } 681 | }) 682 | expect(todoSchema.getInvalidSchemaDataFields({})).toEqual(["name", "user"]) 683 | expect(todoSchema.getInvalidSchemaDataFields({ 684 | name: "my todo" 685 | })).toEqual(["user"]) 686 | expect(todoSchema.getInvalidSchemaDataFields({ 687 | name: "my todo", 688 | user: {} 689 | })).toEqual(["user.name"]) 690 | expect(todoSchema.getInvalidSchemaDataFields({ 691 | name: "my todo", 692 | user: { 693 | name: "John Doe" 694 | } 695 | })).toEqual([]) 696 | expect(todoSchema.isValidFieldValue("user", {})).toBeFalsy(); 697 | expect(todoSchema.isValidFieldValue("user.name", "")).toBeFalsy(); 698 | expect(todoSchema.isValidFieldValue("user", { 699 | name: 'sample' 700 | })).toBeTruthy(); 701 | expect(todoSchema.getField("user.avatar")?.toJSON()).toEqual({ 702 | "defaultValue": "", 703 | "required": false, 704 | "type": "String" 705 | }) 706 | 707 | expect(todoSchema.hasField("user.avatar")).toBeTruthy() 708 | 709 | todoSchema.removeField("user.avatar"); 710 | 711 | expect(todoSchema.hasField("user.avatar")).toBeFalsy() 712 | expect(todoSchema.getField("user")?.toJSON()).toEqual({ 713 | "defaultValue": { 714 | "avatar": "", 715 | "name": "" 716 | }, 717 | "required": true, 718 | "type": "Schema" 719 | }); 720 | }); 721 | 722 | it('should handle checking value against schema', () => { 723 | const userSchema = new Schema("user", { 724 | name: new SchemaValue(String, true), 725 | avatar: new SchemaValue(String), 726 | }); 727 | const itemSchema = new Schema("item", { 728 | name: new SchemaValue(String, true), 729 | description: new SchemaValue(String), 730 | user: new SchemaValue(userSchema, true), 731 | selected: new SchemaValue(Boolean), 732 | count: new SchemaValue(Number), 733 | total: new SchemaValue(OneOf([String, Number], "")), 734 | realTotal: new SchemaValue(Int32Array), 735 | values: new SchemaValue(ArrayOf(Number)), 736 | image: new SchemaValue(Blob), 737 | buffer: new SchemaValue(ArrayBuffer), 738 | }); 739 | 740 | expect(itemSchema.toJSON()).toEqual({ 741 | "buffer": { 742 | "defaultValue": null, 743 | "required": false, 744 | "type": "ArrayBuffer" 745 | }, 746 | "count": { 747 | "defaultValue": 0, 748 | "required": false, 749 | "type": "Number" 750 | }, 751 | "description": { 752 | "defaultValue": "", 753 | "required": false, 754 | "type": "String" 755 | }, 756 | "image": { 757 | "defaultValue": null, 758 | "required": false, 759 | "type": "Blob" 760 | }, 761 | "name": { 762 | "defaultValue": "", 763 | "required": true, 764 | "type": "String" 765 | }, 766 | "realTotal": { 767 | "defaultValue": expect.any(Int32Array), 768 | "required": false, 769 | "type": "Int32Array" 770 | }, 771 | "selected": { 772 | "defaultValue": false, 773 | "required": false, 774 | "type": "Boolean" 775 | }, 776 | "total": { 777 | "defaultValue": "", 778 | "required": false, 779 | "type": "String | Number" 780 | }, 781 | "user": { 782 | "defaultValue": { 783 | "avatar": "", 784 | "name": "" 785 | }, 786 | "required": true, 787 | "type": "Schema" 788 | }, 789 | "values": { 790 | "defaultValue": [], 791 | "required": false, 792 | "type": "Array" 793 | } 794 | }) 795 | expect(itemSchema.toString()).toBe('{\n' + 796 | ' "name": {\n' + 797 | ' "type": "String",\n' + 798 | ' "required": true,\n' + 799 | ' "defaultValue": ""\n' + 800 | ' },\n' + 801 | ' "description": {\n' + 802 | ' "type": "String",\n' + 803 | ' "required": false,\n' + 804 | ' "defaultValue": ""\n' + 805 | ' },\n' + 806 | ' "user": {\n' + 807 | ' "type": "Schema",\n' + 808 | ' "required": true,\n' + 809 | ' "defaultValue": {\n' + 810 | ' "name": "",\n' + 811 | ' "avatar": ""\n' + 812 | ' }\n' + 813 | ' },\n' + 814 | ' "selected": {\n' + 815 | ' "type": "Boolean",\n' + 816 | ' "required": false,\n' + 817 | ' "defaultValue": false\n' + 818 | ' },\n' + 819 | ' "count": {\n' + 820 | ' "type": "Number",\n' + 821 | ' "required": false,\n' + 822 | ' "defaultValue": 0\n' + 823 | ' },\n' + 824 | ' "total": {\n' + 825 | ' "type": "String | Number",\n' + 826 | ' "required": false,\n' + 827 | ' "defaultValue": \"\"\n' + 828 | ' },\n' + 829 | ' "realTotal": {\n' + 830 | ' "type": "Int32Array",\n' + 831 | ' "required": false,\n' + 832 | ' "defaultValue": {}\n' + 833 | ' },\n' + 834 | ' "values": {\n' + 835 | ' "type": "Array",\n' + 836 | ' "required": false,\n' + 837 | ' "defaultValue": []\n' + 838 | ' },\n' + 839 | ' "image": {\n' + 840 | ' "type": "Blob",\n' + 841 | ' "required": false,\n' + 842 | ' "defaultValue": null\n' + 843 | ' },\n' + 844 | ' "buffer": {\n' + 845 | ' "type": "ArrayBuffer",\n' + 846 | ' "required": false,\n' + 847 | ' "defaultValue": null\n' + 848 | ' }\n' + 849 | '}') 850 | expect(itemSchema.getInvalidSchemaDataFields({ 851 | name: "my todo", 852 | user: { 853 | name: "John Doe" 854 | } 855 | })).toEqual([]) 856 | expect(itemSchema.getInvalidSchemaDataFields({ 857 | name: "item", 858 | description: "some desc", 859 | user: { 860 | name: "John Doe" 861 | }, 862 | selected: true, 863 | count: 12, 864 | total: "12", 865 | realTotal: new Int32Array(8), 866 | values: [2, 4, 6], 867 | image: new Blob(), 868 | buffer: new ArrayBuffer(8), 869 | })).toEqual([]) 870 | expect(itemSchema.getInvalidSchemaDataFields({ 871 | name: "item", 872 | description: "some desc", 873 | user: { 874 | name: "John Doe" 875 | }, 876 | selected: true, 877 | count: 12, 878 | total: 12, 879 | realTotal: new Int32Array(8), 880 | values: [2, 4, 6], 881 | image: new Blob(), 882 | buffer: new ArrayBuffer(8), 883 | })).toEqual([]) 884 | expect(itemSchema.getInvalidSchemaDataFields({ 885 | name: "item", 886 | description: "some desc", 887 | user: { 888 | name: "John Doe" 889 | }, 890 | selected: true, 891 | count: "12", 892 | total: true, 893 | realTotal: new Array(8), 894 | values: [true], 895 | image: "sample", 896 | buffer: [], 897 | })).toEqual([ 898 | "count", 899 | "total", 900 | "realTotal", 901 | "values", 902 | "image", 903 | "buffer" 904 | ]) 905 | }); 906 | }); 907 | -------------------------------------------------------------------------------- /src/Schema.ts: -------------------------------------------------------------------------------- 1 | import {isNil} from "./utils/is-nil"; 2 | import {isEmptyString} from "./utils/is-empty-string"; 3 | import {SchemaValue} from "./SchemaValue"; 4 | import {isSameValueType} from "./utils/is-same-value-type"; 5 | import {SchemaId} from "./CustomTypes/SchemaId"; 6 | import {SchemaJSON, SchemaValueConstructorType, SchemaValueMap} from "./types"; 7 | 8 | export class Schema { 9 | #obj: SchemaValueMap = {}; 10 | #name: string; 11 | 12 | constructor(name: string, map: SchemaValueMap | null = null) { 13 | this.#name = name; 14 | 15 | if (map) { 16 | for (let objKey in map) { 17 | if (map.hasOwnProperty(objKey)) { 18 | if (map[objKey] instanceof SchemaValue) { 19 | this.#obj[objKey] = map[objKey]; 20 | } else { 21 | throw new Error(`Field "${objKey}" is not a SchemaValue`) 22 | } 23 | } 24 | } 25 | } 26 | } 27 | 28 | get name() { 29 | return this.#name; 30 | } 31 | 32 | defineField(name: string | keyof T, type: SchemaValueConstructorType | Schema, { 33 | defaultValue, 34 | required 35 | }: { defaultValue?: any, required?: boolean } = {}) { 36 | this.#obj[String(name)] = new SchemaValue(type, required, defaultValue); 37 | } 38 | 39 | removeField(name: string | keyof T): void { 40 | if (name) { 41 | const [first, ...others] = String(name).split("."); 42 | 43 | const field = this.#obj[first]; 44 | 45 | if (field) { 46 | if (others.length) { 47 | if (field.type instanceof Schema) { 48 | field.type.removeField(others.join('.')); 49 | } 50 | } else { 51 | delete this.#obj[first]; 52 | } 53 | } 54 | } 55 | } 56 | 57 | hasField(name: string | keyof T): boolean { 58 | if (name) { 59 | const [first, ...others] = String(name).split("."); 60 | 61 | const field = this.#obj[first]; 62 | 63 | if (field) { 64 | if (others.length) { 65 | if (field.type instanceof Schema) { 66 | return field.type.hasField(others.join('.')) 67 | } 68 | } else { 69 | return this.#obj.hasOwnProperty(first); 70 | } 71 | } 72 | } 73 | 74 | return false; 75 | } 76 | 77 | getField(name: string | keyof T): SchemaValue | null { 78 | if (name) { 79 | const [first, ...others] = String(name).split("."); 80 | 81 | const field = this.#obj[first]; 82 | 83 | if (field && others.length) { 84 | if (field.type instanceof Schema) { 85 | return field.type.getField(others.join('.')) 86 | } 87 | } 88 | 89 | return field ?? null; 90 | } 91 | 92 | return null 93 | } 94 | 95 | isValidFieldValue(name: string | keyof T, value: any = null): boolean { 96 | const val = this.getField(String(name)); 97 | 98 | if (val) { 99 | return val.required 100 | ? !isNil(value) && (val.type !== String || !isEmptyString(value)) && isSameValueType(val.type, value) 101 | : isSameValueType(val.type, value); 102 | } 103 | 104 | return false; 105 | } 106 | 107 | getInvalidSchemaDataFields(value: Record, defaultKeys: Set = new Set()): string[] { 108 | const invalidFields: Set = new Set(); 109 | 110 | const requiredFields = Object.keys(this.#obj).filter(key => this.#obj[key].required); 111 | const keys = [...Object.keys(value as {}), ...requiredFields]; 112 | 113 | for (const valueKey of keys) { 114 | if (!defaultKeys.has(valueKey as string)) { 115 | const schemaVal = this.getField(valueKey as keyof T); 116 | const val = value[valueKey]; 117 | 118 | if (/ArrayOf/.test(schemaVal?.type.name ?? '')) { 119 | if (!(val instanceof Array)) { 120 | invalidFields.add(valueKey); 121 | continue; 122 | } else { 123 | // @ts-ignore 124 | const Type = (new (schemaVal.type as any)()); 125 | 126 | if(Type.type instanceof Schema) { 127 | val.forEach((v, k) => { 128 | if (`${v}` === '[object Object]') { 129 | Type.type.getInvalidSchemaDataFields(v).forEach((z: string) => { 130 | invalidFields.add(`${valueKey}[${k}].${z}`) 131 | }) 132 | } else { 133 | invalidFields.add(`${valueKey}[${k}]`); 134 | } 135 | }) 136 | continue; 137 | } 138 | } 139 | } 140 | 141 | if (/OneOf/.test(schemaVal?.type.name ?? '')) { 142 | // @ts-ignore 143 | const Type = (new (schemaVal.type as any)()); 144 | const schema = Type.type.find((t: any) => t instanceof Schema); 145 | 146 | if (schema && `${val}` === '[object Object]') { 147 | schema.getInvalidSchemaDataFields(val).forEach((k: string) => { 148 | invalidFields.add(`${valueKey}.${k}`) 149 | }) 150 | } else { 151 | if (!this.isValidFieldValue(valueKey as keyof T, val)) { 152 | invalidFields.add(valueKey); 153 | } 154 | } 155 | 156 | continue; 157 | } 158 | 159 | if (schemaVal?.type instanceof Schema) { 160 | if (`${val}` === '[object Object]') { 161 | schemaVal.type.getInvalidSchemaDataFields(val).forEach((k: string) => { 162 | invalidFields.add(`${valueKey}.${k}`) 163 | }) 164 | } else { 165 | invalidFields.add(valueKey); 166 | } 167 | continue; 168 | } 169 | 170 | if (!this.isValidFieldValue(valueKey as keyof T, val)) { 171 | invalidFields.add(valueKey); 172 | } 173 | } 174 | } 175 | 176 | return Array.from(invalidFields); 177 | } 178 | 179 | toJSON(): SchemaJSON { 180 | const json: SchemaJSON = {}; 181 | 182 | for (let mapKey in this.#obj) { 183 | if (this.#obj.hasOwnProperty(mapKey)) { 184 | const val = this.#obj[mapKey]; 185 | json[mapKey] = val.toJSON(); 186 | } 187 | } 188 | 189 | return json; 190 | } 191 | 192 | toString() { 193 | return JSON.stringify(this.toJSON(), null, 4) 194 | } 195 | 196 | toValue(): T { 197 | const nowDate = new Date(); 198 | 199 | const obj: { [k: string]: any } = {}; 200 | 201 | for (let mapKey in this.#obj) { 202 | if (this.#obj.hasOwnProperty(mapKey)) { 203 | const val = this.#obj[mapKey]; 204 | 205 | switch (true) { 206 | case val.type instanceof Schema: 207 | obj[mapKey] = (val.type as Schema).toValue(); 208 | break; 209 | case val.type === SchemaId: 210 | obj[mapKey] = (new SchemaId()).defaultValue; 211 | break; 212 | case val.type === Date: 213 | obj[mapKey] = val.defaultValue instanceof Date ? val.defaultValue : nowDate; 214 | break; 215 | default: 216 | obj[mapKey] = val.defaultValue; 217 | } 218 | } 219 | } 220 | 221 | return obj as T; 222 | } 223 | } 224 | -------------------------------------------------------------------------------- /src/SchemaValue.spec.ts: -------------------------------------------------------------------------------- 1 | import {SchemaValue} from "./SchemaValue"; 2 | import {SchemaId} from "./CustomTypes/SchemaId"; 3 | import {ArrayOf} from "./CustomTypes/ArrayOf"; 4 | import {Schema} from "./Schema"; 5 | import {OneOf} from "./CustomTypes/OneOf"; 6 | import {Null} from "./CustomTypes/Null"; 7 | 8 | describe('SchemaValue', () => { 9 | it('should create', () => { 10 | const userSchema = new Schema("user"); 11 | const todoSchema = new Schema("todo"); 12 | 13 | userSchema.defineField("name", String, {required: true}); 14 | 15 | todoSchema.defineField("name", String, {required: true}); 16 | todoSchema.defineField("description", String); 17 | todoSchema.defineField("complete", Boolean); 18 | todoSchema.defineField("user", userSchema, {required: true}); 19 | 20 | expect((new SchemaValue(todoSchema)).toJSON()).toEqual({ 21 | "defaultValue": { 22 | "complete": false, 23 | "description": "", 24 | "name": "", 25 | "user": { 26 | "name": "" 27 | } 28 | }, 29 | "required": false, 30 | "type": "Schema" 31 | }) 32 | expect((new SchemaValue(todoSchema)).toJSON()).toEqual({ 33 | "defaultValue": { 34 | "complete": false, 35 | "description": "", 36 | "name": "", 37 | "user": { 38 | "name": "" 39 | } 40 | }, 41 | "required": false, 42 | "type": "Schema" 43 | }) 44 | expect((new SchemaValue(Schema, false, {})).toJSON()).toEqual({ 45 | "defaultValue": {}, 46 | "required": false, 47 | "type": "Schema" 48 | }) 49 | expect((new SchemaValue(Number)).toJSON()).toEqual({ 50 | "defaultValue": 0, 51 | "required": false, 52 | "type": "Number" 53 | }) 54 | expect((new SchemaValue(String, false, "sample")).toJSON()).toEqual(expect.objectContaining({ 55 | "defaultValue": "sample", 56 | "required": false, 57 | "type": "String" 58 | })) 59 | expect((new SchemaValue(Boolean)).toJSON()).toEqual({ 60 | "defaultValue": false, 61 | "required": false, 62 | "type": "Boolean" 63 | }) 64 | expect((new SchemaValue(Date)).toJSON()).toEqual({ 65 | "defaultValue": null, 66 | "required": false, 67 | "type": "Date" 68 | }) 69 | expect((new SchemaValue(Blob, false)).toJSON()).toEqual({ 70 | "defaultValue": null, 71 | "required": false, 72 | "type": "Blob" 73 | }) 74 | expect((new SchemaValue(ArrayBuffer)).toJSON()).toEqual({ 75 | "defaultValue": null, 76 | "required": false, 77 | "type": "ArrayBuffer" 78 | }) 79 | expect((new SchemaValue(Int32Array)).toJSON()).toEqual({ 80 | "defaultValue": expect.any(Int32Array), 81 | "required": false, 82 | "type": "Int32Array" 83 | }) 84 | expect((new SchemaValue(SchemaId, true)).toJSON()).toEqual(expect.objectContaining({ 85 | "defaultValue": expect.any(String), 86 | "required": true, 87 | "type": "SchemaId" 88 | })) 89 | expect((new SchemaValue(Array, true)).toJSON()).toEqual(expect.objectContaining({ 90 | "defaultValue": [], 91 | "required": true, 92 | "type": "Array" 93 | })) 94 | expect((new SchemaValue(ArrayOf(String), true, [])).toJSON()).toEqual(expect.objectContaining({ 95 | "defaultValue": [], 96 | "required": true, 97 | "type": "Array" 98 | })) 99 | expect((new SchemaValue(ArrayOf({$name: String}), true, [])).toJSON()).toEqual(expect.objectContaining({ 100 | "defaultValue": [], 101 | "required": true, 102 | "type": "Array" 103 | })) 104 | expect((new SchemaValue(OneOf([String, Number], ""), false, 12)).toJSON()).toEqual(expect.objectContaining({ 105 | "defaultValue": 12, 106 | "required": false, 107 | "type": "String | Number" 108 | })) 109 | expect((new SchemaValue(OneOf([String, {name: String}], ""), false, "john doe")).toJSON()).toEqual(expect.objectContaining({ 110 | "defaultValue": "john doe", 111 | "required": false, 112 | "type": "String | Schema" 113 | })) 114 | 115 | expect((new SchemaValue(OneOf([Number, Null], 12), false)).toJSON()).toEqual(expect.objectContaining({ 116 | "defaultValue": 12, 117 | "required": false, 118 | "type": "Number | Null" 119 | })) 120 | 121 | expect((new SchemaValue(ArrayOf(OneOf([String, Number], "")), false, [0, 1, 2])).toJSON()).toEqual(expect.objectContaining({ 122 | "defaultValue": [0, 1, 2], 123 | "required": false, 124 | "type": "Array" 125 | })) 126 | 127 | expect((new SchemaValue(Null, true)).toJSON()).toEqual(expect.objectContaining({ 128 | "defaultValue": null, 129 | "required": true, 130 | "type": "Null" 131 | })) 132 | }); 133 | 134 | it('should throw error if invalid default value type', () => { 135 | const userSchema = new Schema("user"); 136 | 137 | userSchema.defineField("name", String, {required: true}); 138 | 139 | expect(() => new SchemaValue(Blob, true, null)).toThrowError(`Default value does not match type "Blob"`) 140 | expect(() => new SchemaValue(SchemaId, true, null)).toThrowError(`Default value does not match type "SchemaId"`) 141 | expect(() => new SchemaValue(String, false, 12)).toThrowError(`Default value does not match type "String"`) 142 | expect(() => new SchemaValue(Number, false, true)).toThrowError(`Default value does not match type "Number"`) 143 | expect(() => new SchemaValue(Boolean, false, "")).toThrowError(`Default value does not match type "Boolean"`) 144 | expect(() => new SchemaValue(Date, false, null)).toThrowError(`Default value does not match type "Date"`) 145 | expect(() => new SchemaValue(Date, false, new Date())).not.toThrowError() 146 | expect(() => new SchemaValue(Date, false)).not.toThrowError() 147 | expect(() => new SchemaValue(ArrayBuffer, true, [])).toThrowError(`Default value does not match type "ArrayBuffer"`) 148 | expect(() => new SchemaValue(Int32Array, true, [])).toThrowError(`Default value does not match type "Int32Array"`) 149 | expect(() => new SchemaValue(Blob, true, {})).toThrowError(`Default value does not match type "Blob"`) 150 | expect(() => new SchemaValue(userSchema, true, {})).toThrowError(`Default value does not match type "Schema"`) 151 | expect(() => new SchemaValue(SchemaId, true, "sample")).toThrowError(`Default value does not match type "SchemaId"`) 152 | expect(() => new SchemaValue(SchemaId, true, {} as any)).toThrowError(`Default value does not match type "SchemaId"`) 153 | expect(() => new SchemaValue(OneOf([String], ""), false, 12)).toThrowError(`OneOf requires more than single type listed comma separated`) 154 | expect(() => new SchemaValue(OneOf([String, Boolean], ""), false, 12)).toThrowError('Default value does not match type "String | Boolean"') 155 | expect(() => new SchemaValue(OneOf([String, Boolean], ""), false, false)).not.toThrowError() 156 | expect(() => new SchemaValue(OneOf([String, OneOf([Number, Boolean], false)], ""), false, false)).toThrowError('Cannot nest "OneOf" types') 157 | expect(() => new SchemaValue(ArrayOf(String), true, 12)).toThrowError('Default value does not match type "Array"') 158 | expect(() => new SchemaValue(ArrayOf(String), true)).not.toThrowError() 159 | expect(() => new SchemaValue(Null, true)).not.toThrowError() 160 | expect(() => new SchemaValue(Null, true, undefined)).not.toThrowError() 161 | }); 162 | 163 | it('should throw error if invalid value type', () => { 164 | // @ts-ignore 165 | expect(() => new SchemaValue(Function)).toThrowError(`Invalid SchemaValue type provided. Received "Function"`) 166 | }); 167 | }) 168 | -------------------------------------------------------------------------------- /src/SchemaValue.ts: -------------------------------------------------------------------------------- 1 | import {isSameValueType} from "./utils/is-same-value-type"; 2 | import {getDefaultValue} from "./utils/get-default-value"; 3 | import {JSONValue, SchemaJSON, SchemaValueConstructorType, SchemaValueType} from "./types"; 4 | import {Schema} from "./Schema"; 5 | import {CustomType} from "./CustomTypes/CustomType"; 6 | import {SchemaId} from "./CustomTypes/SchemaId"; 7 | import {isSupportedType} from "./utils/is-supported-type"; 8 | 9 | export class SchemaValue { 10 | #type: string = ""; 11 | 12 | constructor( 13 | public type: SchemaValueConstructorType | Schema, 14 | public required = false, 15 | public defaultValue?: SchemaValueType | SchemaJSON 16 | ) { 17 | if (!(type instanceof Schema) && !isSupportedType(type as SchemaValueConstructorType)) { 18 | // @ts-ignore 19 | throw new Error(`Invalid SchemaValue type provided. Received "${type?.name ?? (new type())?.name}"`) 20 | } 21 | 22 | this.#type = this.type instanceof Schema 23 | ? `Schema<${this.type.name}>` 24 | : /ArrayOf|OneOf|SchemaId|Null/.test(type.name) 25 | // @ts-ignore 26 | ? (new type()).name 27 | : this.type.name 28 | 29 | // if the default value is not undefined treat it as value set 30 | // and make sure it is of same type 31 | if (defaultValue !== undefined && !isSameValueType(type, defaultValue)) { 32 | throw new Error(`Default value does not match type "${this.#type}"`); 33 | } 34 | 35 | this.defaultValue = defaultValue !== undefined ? 36 | defaultValue instanceof CustomType || defaultValue instanceof SchemaId 37 | ? defaultValue.defaultValue 38 | : defaultValue : getDefaultValue(this.type); 39 | } 40 | 41 | toJSON(): JSONValue { 42 | return { 43 | type: this.#type, 44 | required: this.required, 45 | defaultValue: this.defaultValue as SchemaValueType | SchemaJSON 46 | } 47 | } 48 | 49 | toString() { 50 | return JSON.stringify(this.toJSON(), null, 4) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/client.ts: -------------------------------------------------------------------------------- 1 | import {Schema} from './Schema'; 2 | import {SchemaId} from './CustomTypes/SchemaId'; 3 | import {ArrayOf} from './CustomTypes/ArrayOf'; 4 | import {OneOf} from './CustomTypes/OneOf'; 5 | import {Null} from './CustomTypes/Null'; 6 | import {SchemaValue} from './SchemaValue'; 7 | import {ClientStore} from './ClientStore'; 8 | import {AppState} from './AppState'; 9 | import {EventType, StorageType} from './types'; 10 | import {DefaultStoreState, withClientStore} from './helpers/with-client-store'; 11 | 12 | // @ts-ignore 13 | if (window) { 14 | // @ts-ignore 15 | window.CWS = { 16 | Schema, 17 | SchemaValue, 18 | SchemaId, 19 | ArrayOf, 20 | OneOf, 21 | Null, 22 | ClientStore, 23 | EventType, 24 | StorageType, 25 | AppState, 26 | withClientStore, 27 | DefaultStoreState 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/default-config.ts: -------------------------------------------------------------------------------- 1 | import {MEMORYSTORAGE} from "./MemoryStore"; 2 | import {Config} from "./types"; 3 | 4 | export const defaultConfig: Config = { 5 | version: 1, 6 | type: MEMORYSTORAGE, 7 | description: "", 8 | appName: "App", 9 | idKeyName: "_id", 10 | createdDateKeyName: "_createdDate", 11 | updatedDateKeyName: "_lastUpdatedDate", 12 | } 13 | -------------------------------------------------------------------------------- /src/helpers/use-app-state.tsx: -------------------------------------------------------------------------------- 1 | import React, {useContext, useEffect, useMemo, useState} from "react"; 2 | import {AppState} from "../AppState"; 3 | 4 | const AppStateContext = React.createContext>>({}); 5 | 6 | interface AppStateProviderProps { 7 | states: AppState[], 8 | children: React.ReactNode 9 | } 10 | 11 | export const AppStateProvider = ({states, children}: AppStateProviderProps) => { 12 | const stateMap = useMemo(() => (states ?? []).reduce((acc, state) => ({...acc, [state.name]: state}), {}), [states]); 13 | 14 | return 15 | {children} 16 | 17 | } 18 | 19 | export const useAppState = (appStateNameOrInstance: string | AppState): {error: Error | null, state: T, setState: (data: Partial) => Promise} => { 20 | const appStates = useContext(AppStateContext); 21 | const appState = typeof appStateNameOrInstance === "string" ? appStates[appStateNameOrInstance] : appStateNameOrInstance; 22 | const [value, setValue] = useState(appState.value); 23 | const [error, setError] = useState(null); 24 | 25 | useEffect(() => { 26 | return appState.subscribe((data, error) => { 27 | if (error) { 28 | setError(error) 29 | } else { 30 | setValue(data as T) 31 | } 32 | }); 33 | }, []); 34 | 35 | return { 36 | state: value, 37 | error, 38 | setState: (...args) => appState.update(...args), 39 | }; 40 | } 41 | -------------------------------------------------------------------------------- /src/helpers/use-client-store.tsx: -------------------------------------------------------------------------------- 1 | import React, {useContext, useEffect, useMemo, useState} from "react"; 2 | import {ClientStore} from "../ClientStore"; 3 | import {StoreState} from "../types"; 4 | import {DefaultStoreState, withClientStore} from "./with-client-store"; 5 | 6 | const ClientStoreContext = React.createContext>>({}); 7 | 8 | interface ClientStoreProviderProps { 9 | stores: ClientStore[], 10 | children: React.ReactNode 11 | } 12 | 13 | export const ClientStoreProvider = ({stores, children}: ClientStoreProviderProps) => { 14 | const storeMap = useMemo(() => stores.reduce((acc, store) => ({...acc, [store.name]: store}), {}), [stores]); 15 | 16 | return 17 | {children} 18 | 19 | } 20 | 21 | export const useClientStore = (storeNameOrInstance: string | ClientStore): StoreState => { 22 | const stores = useContext(ClientStoreContext); 23 | const clientStore: ClientStore = typeof storeNameOrInstance === "string" ? stores[storeNameOrInstance] : storeNameOrInstance; 24 | const [data, updateData] = useState>(DefaultStoreState(clientStore)); 25 | 26 | useEffect(() => { 27 | if (!(clientStore instanceof ClientStore)) { 28 | throw new Error(`Could Not Find Client Store "${clientStore}"`); 29 | } 30 | 31 | return withClientStore(clientStore, updateData); 32 | }, []); 33 | 34 | return data; 35 | } 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /src/helpers/with-client-store.ts: -------------------------------------------------------------------------------- 1 | import {ActionEventData, EventType, StoreState, UnSubscriber} from "../types"; 2 | import {ClientStore} from "../ClientStore"; 3 | 4 | export const DefaultStoreState = (store: ClientStore): StoreState => ({ 5 | items: [], 6 | processing: false, 7 | creatingItems: false, 8 | updatingItems: false, 9 | deletingItems: false, 10 | loadingItems: false, 11 | clearingItems: false, 12 | error: null, 13 | createItem: (...args) => store.createItem(...args), 14 | updateItem: (...args) => store.updateItem(...args), 15 | loadItems: (...args) => store.loadItems(...args), 16 | removeItem: (...args) => store.removeItem(...args), 17 | findItems: (...args) => store.findItems(...args), 18 | findItem: (...args) => store.findItem(...args), 19 | clear: (...args) => store.clear(...args), 20 | }); 21 | 22 | export const withClientStore = (store: ClientStore, cb: (data: StoreState) => void): UnSubscriber => { 23 | let data: StoreState = DefaultStoreState(store); 24 | data.processing = !store.ready; 25 | 26 | const loadItems = () => { 27 | store.getItems() 28 | .then(items => { 29 | data.items = items; 30 | cb(data); 31 | }) 32 | } 33 | 34 | if (store.ready) { 35 | loadItems() 36 | } 37 | 38 | const updateStatuses = (events: Set) => { 39 | data.processing = events.size > 0; 40 | data.creatingItems = events.has(EventType.CREATED); 41 | data.updatingItems = events.has(EventType.UPDATED); 42 | data.deletingItems = events.has(EventType.REMOVED); 43 | data.loadingItems = events.has(EventType.LOADED); 44 | data.clearingItems = events.has(EventType.CLEARED); 45 | } 46 | 47 | return store.subscribe(async (eventType, details) => { 48 | switch (eventType) { 49 | case EventType.READY: 50 | data.items = await store.getItems(); 51 | break; 52 | case EventType.ERROR: 53 | const {action, error} = details as ActionEventData; 54 | 55 | data.error = new Error(`${action}: ${error?.message}`); 56 | break; 57 | case EventType.PROCESSING_EVENTS: 58 | updateStatuses(new Set(details as EventType[])) 59 | break; 60 | case EventType.CREATED: 61 | data = {...data, creatingItems: false, items: await store.getItems(), error: null}; 62 | break; 63 | case EventType.UPDATED: 64 | data = {...data, updatingItems: false, items: await store.getItems(), error: null}; 65 | break; 66 | case EventType.REMOVED: 67 | data = {...data, deletingItems: false, items: await store.getItems(), error: null}; 68 | break; 69 | case EventType.LOADED: 70 | data = {...data, loadingItems: false, items: await store.getItems(), error: null}; 71 | break; 72 | case EventType.CLEARED: 73 | data = {...data, clearingItems: false, items: await store.getItems(), error: null}; 74 | break; 75 | } 76 | 77 | cb(data); 78 | }) 79 | } 80 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './types'; 2 | export {Schema} from './Schema'; 3 | export {SchemaId} from './CustomTypes/SchemaId'; 4 | export {ArrayOf} from './CustomTypes/ArrayOf'; 5 | export {OneOf} from './CustomTypes/OneOf'; 6 | export {Null} from './CustomTypes/Null'; 7 | export {SchemaValue} from './SchemaValue'; 8 | export {ClientStore} from './ClientStore'; 9 | export {AppState} from './AppState'; 10 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import {SchemaValue} from "./SchemaValue"; 2 | import {CustomType} from "./CustomTypes/CustomType"; 3 | import {Schema} from "./Schema"; 4 | import {INDEXEDDB, LOCALSTORAGE, WEBSQL} from "localforage"; 5 | import {MEMORYSTORAGE} from "./MemoryStore"; 6 | 7 | export interface BlobConstructor { 8 | prototype: Blob; 9 | 10 | new(blobParts?: BlobPart[], options?: BlobPropertyBag): Blob; 11 | } 12 | 13 | export type SchemaObjectLiteral = Record>; 14 | 15 | export type SchemaValueConstructorType = 16 | | typeof CustomType 17 | | typeof Schema 18 | | DateConstructor 19 | | NumberConstructor 20 | | StringConstructor 21 | | BooleanConstructor 22 | | ArrayConstructor 23 | | ArrayBufferConstructor 24 | | BlobConstructor 25 | | Float32ArrayConstructor 26 | | Float64ArrayConstructor 27 | | Int8ArrayConstructor 28 | | Int16ArrayConstructor 29 | | Int32ArrayConstructor 30 | | Uint8ArrayConstructor 31 | | Uint8ClampedArrayConstructor 32 | | Uint16ArrayConstructor 33 | | Uint32ArrayConstructor; 34 | 35 | export type SchemaValueType = null 36 | | Schema 37 | | CustomType 38 | | Date 39 | | Number 40 | | String 41 | | Boolean 42 | | Array 43 | | ArrayBuffer 44 | | Blob 45 | | Float32Array 46 | | Float64Array 47 | | Int8Array 48 | | Int16Array 49 | | Int32Array 50 | | Uint8Array 51 | | Uint8ClampedArray 52 | | Uint16Array 53 | | Uint32Array; 54 | 55 | export interface JSONValue { 56 | type: string; 57 | required: boolean; 58 | defaultValue: SchemaValueType | SchemaJSON; 59 | } 60 | 61 | export interface SchemaJSON { 62 | [k: string]: JSONValue | SchemaJSON 63 | } 64 | 65 | export interface SchemaValueMap { 66 | [k: string]: SchemaValue 67 | } 68 | 69 | export interface SchemaDefaultValues { 70 | _id: string; 71 | _createdDate: Date; 72 | _lastUpdatedDate: Date; 73 | } 74 | 75 | export interface ActionEventData { 76 | data: D, 77 | error: Error | null, 78 | action: EventType, 79 | id: string | null 80 | } 81 | 82 | interface InterceptData { data: EventData, id: string | null } 83 | 84 | export type EventData = T | T[] | Partial | Partial[] |string | string[] | EventType[] | boolean | ActionEventData; 85 | 86 | export type StoreSubscriber = (eventType: EventType, data: EventData) => void; 87 | 88 | export type EventHandler = (data: EventData) => void; 89 | 90 | export type UnSubscriber = () => void; 91 | 92 | type BeforeChangeHandlerReturn = null | ActionEventData> | void; 93 | 94 | type InterceptEventHandlerReturn = null | ActionEventData> | void; 95 | 96 | export type BeforeChangeHandler = (eventType: EventType, data: InterceptData) => Promise> | BeforeChangeHandlerReturn; 97 | 98 | export type InterceptEventHandler = (data: InterceptData) => Promise> | InterceptEventHandlerReturn; 99 | 100 | export interface Config { 101 | appName?: string; 102 | version?: number; 103 | type?: string | string[]; 104 | description?: string; 105 | idKeyName?: string; 106 | createdDateKeyName?: string; 107 | updatedDateKeyName?: string; 108 | } 109 | 110 | export enum EventType { 111 | READY = "ready", 112 | PROCESSING = "processing", 113 | PROCESSING_EVENTS = "processing-events", 114 | CREATED = "created", 115 | LOADED = "loaded", 116 | ERROR = "error", 117 | ABORTED = "aborted", 118 | REMOVED = "removed", 119 | UPDATED = "updated", 120 | CLEARED = "cleared" 121 | } 122 | 123 | export const StorageType = { 124 | LOCALSTORAGE, 125 | WEBSQL, 126 | INDEXEDDB, 127 | MEMORYSTORAGE 128 | } 129 | 130 | export interface StoreState { 131 | items: T[]; 132 | processing: boolean; 133 | creatingItems: boolean; 134 | updatingItems: boolean; 135 | deletingItems: boolean; 136 | loadingItems: boolean; 137 | clearingItems: boolean; 138 | error: Error | null; 139 | loadItems: (dataList: Array>) => Promise & SchemaDefaultValues>>; 140 | createItem: (data: Partial) => Promise & SchemaDefaultValues>; 141 | updateItem: (id: string, data: Partial) => Promise & SchemaDefaultValues>; 142 | removeItem: (id: string) => Promise; 143 | clear: () => Promise; 144 | findItem: (cb?: (value: Required, key: string) => boolean) => Promise; 145 | findItems: (cb?: (value: Required, key: string) => boolean) => Promise 146 | } 147 | 148 | -------------------------------------------------------------------------------- /src/utils/error-messages.ts: -------------------------------------------------------------------------------- 1 | export const errorMessages = { 2 | blankStoreName: () => 'ClientStore must have a non-blank name', 3 | invalidSchema: () => 'Invalid "Schema" instance or object', 4 | invalidSubHandler: (sub: any) => `Received invalid "subscribe" handler => ${sub}`, 5 | invalidEventName: (type: string, eventName: string) => `Received unknown ${type} "${eventName}" event`, 6 | invalidEventHandler: (type: string, eventName: string, handler: any) => `Received invalid ${type} "${eventName}" event handler => ${handler}`, 7 | invalidValueProvided: (action: string, data: any) => `Invalid "value" provided to ${action} item => ${data}`, 8 | invalidValueInterceptProvided: (action: string, data: any) => `Invalid "value" returned via ${action} intercept handler - item => ${data}`, 9 | missingOrInvalidFields: (invalidFields: string[], invalidFieldTypes: any[]) => `Missing or invalid field types for "${invalidFields.join(', ')}" keys. Should be ${invalidFieldTypes.map((type, idx) => `[${invalidFields[idx]}, ${type}]`).join(', ')}`, 10 | } 11 | -------------------------------------------------------------------------------- /src/utils/generate-uuid.ts: -------------------------------------------------------------------------------- 1 | // https://stackoverflow.com/questions/105034/how-to-create-a-guid-uuid 2 | export const generateUUID = () => { 3 | let d = new Date().getTime();//Timestamp 4 | let d2 = ((typeof performance !== 'undefined') && performance.now && (performance.now()*1000)) || 0;//Time in microseconds since page-load or 0 if unsupported 5 | 6 | return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { 7 | let r = Math.random() * 16; //random number between 0 and 16 8 | 9 | if(d > 0){ //Use timestamp until depleted 10 | r = (d + r)%16 | 0; 11 | d = Math.floor(d/16); 12 | } else {//Use microseconds since page-load if supported 13 | r = (d2 + r)%16 | 0; 14 | d2 = Math.floor(d2/16); 15 | } 16 | return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16); 17 | }); 18 | } 19 | -------------------------------------------------------------------------------- /src/utils/get-default-value.ts: -------------------------------------------------------------------------------- 1 | import {SchemaJSON, SchemaValueConstructorType, SchemaValueType} from "../types"; 2 | import {Schema} from "../Schema"; 3 | 4 | export const getDefaultValue = (Type: SchemaValueConstructorType | Schema): SchemaValueType | SchemaJSON => { 5 | switch (Type) { 6 | case Number: 7 | return 0; 8 | case Boolean: 9 | return false; 10 | case String: 11 | return ""; 12 | case Array: 13 | case Float32Array: 14 | case Float64Array: 15 | case Int8Array: 16 | case Int16Array: 17 | case Int32Array: 18 | case Uint8Array: 19 | case Uint8ClampedArray: 20 | case Uint16Array: 21 | case Uint32Array: 22 | return new Type(); 23 | default: 24 | // Custom types 25 | if (/SchemaId|ArrayOf|OneOf|Null/.test(Type.name)) { 26 | // @ts-ignore 27 | return ((new (Type)()).defaultValue); 28 | } 29 | 30 | if (Type instanceof Schema) { 31 | return Object.entries(Type.toJSON()).reduce((acc, [k, val]) => ({...acc, [k]: val.defaultValue}), {}); 32 | } 33 | 34 | return null; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/utils/get-schema-type-and-default-value-from-value.spec.ts: -------------------------------------------------------------------------------- 1 | import {getSchemaTypeAndDefaultValueFromValue} from "./get-schema-type-and-default-value-from-value"; 2 | import {ArrayOf} from "../CustomTypes/ArrayOf"; 3 | import {OneOf} from "../CustomTypes/OneOf"; 4 | import {SchemaId} from "../CustomTypes/SchemaId"; 5 | import {Schema} from "../Schema"; 6 | 7 | describe('getSchemaTypeAndDefaultValueFromValue', () => { 8 | it('should ', () => { 9 | expect(1).toBe(1) 10 | }); 11 | 12 | it('should handle primitives', () => { 13 | expect(getSchemaTypeAndDefaultValueFromValue(12)).toEqual({ 14 | defaultValue: 12, 15 | type: Number 16 | }) 17 | expect(getSchemaTypeAndDefaultValueFromValue(Number)).toEqual({ 18 | defaultValue: 0, 19 | type: Number 20 | }) 21 | expect(getSchemaTypeAndDefaultValueFromValue('str')).toEqual({ 22 | defaultValue: 'str', 23 | type: String 24 | }) 25 | expect(getSchemaTypeAndDefaultValueFromValue(String)).toEqual({ 26 | defaultValue: '', 27 | type: String 28 | }) 29 | expect(getSchemaTypeAndDefaultValueFromValue(false)).toEqual({ 30 | defaultValue: false, 31 | type: Boolean 32 | }) 33 | expect(getSchemaTypeAndDefaultValueFromValue(Boolean)).toEqual({ 34 | defaultValue: false, 35 | type: Boolean 36 | }) 37 | }); 38 | 39 | it('should handle date', () => { 40 | const date = new Date(); 41 | 42 | expect(getSchemaTypeAndDefaultValueFromValue(date)).toEqual({ 43 | defaultValue: date, 44 | type: Date 45 | }) 46 | expect(getSchemaTypeAndDefaultValueFromValue(Date)).toEqual({ 47 | defaultValue: null, 48 | type: Date 49 | }) 50 | expect(getSchemaTypeAndDefaultValueFromValue(Date.now())).toEqual({ 51 | defaultValue: expect.any(Number), 52 | type: Number 53 | }) 54 | }); 55 | 56 | it('should handle Array', () => { 57 | expect(getSchemaTypeAndDefaultValueFromValue([])).toEqual({ 58 | defaultValue: [], 59 | type: Array 60 | }) 61 | expect(getSchemaTypeAndDefaultValueFromValue([12, 'str'])).toEqual({ 62 | defaultValue: [12, 'str'], 63 | type: Array 64 | }) 65 | expect(getSchemaTypeAndDefaultValueFromValue(Array)).toEqual({ 66 | defaultValue: [], 67 | type: Array 68 | }) 69 | expect(getSchemaTypeAndDefaultValueFromValue(new Array())).toEqual({ 70 | defaultValue: [], 71 | type: Array 72 | }) 73 | expect(getSchemaTypeAndDefaultValueFromValue(Array())).toEqual({ 74 | defaultValue: [], 75 | type: Array 76 | }) 77 | }); 78 | 79 | it('should handle ArrayBuffer', () => { 80 | const buffer = new ArrayBuffer(12); 81 | 82 | expect(getSchemaTypeAndDefaultValueFromValue(buffer)).toEqual({ 83 | defaultValue: buffer, 84 | type: ArrayBuffer 85 | }) 86 | expect(getSchemaTypeAndDefaultValueFromValue(ArrayBuffer)).toEqual({ 87 | defaultValue: null, 88 | type: ArrayBuffer 89 | }) 90 | }); 91 | 92 | it('should handle ArrayOf', () => { 93 | let res = getSchemaTypeAndDefaultValueFromValue([12, 34, 7]); 94 | 95 | expect(res.defaultValue).toEqual([12, 34, 7]); 96 | expect(res.type?.name).toEqual('ArrayOf'); 97 | 98 | res = getSchemaTypeAndDefaultValueFromValue(ArrayOf(String)); 99 | 100 | expect(res.defaultValue).toEqual([]); 101 | expect(res.type?.name).toEqual('ArrayOf'); 102 | }); 103 | 104 | it('should handle Typed Array', () => { 105 | [ 106 | new Float32Array(), 107 | new Float64Array(), 108 | new Int8Array(), 109 | new Int16Array(), 110 | new Int32Array(), 111 | new Uint8Array(), 112 | new Uint8ClampedArray(), 113 | new Uint16Array(), 114 | new Uint32Array(), 115 | ].forEach(val => { 116 | expect(getSchemaTypeAndDefaultValueFromValue(val)).toEqual({ 117 | defaultValue: val, 118 | type: val.constructor 119 | }) 120 | 121 | // @ts-ignore 122 | expect(getSchemaTypeAndDefaultValueFromValue(val.constructor)).toEqual({ 123 | // @ts-ignore 124 | defaultValue: new val.constructor(), 125 | type: val.constructor 126 | }) 127 | }) 128 | }); 129 | 130 | it('should handle Blob', () => { 131 | const blob = new Blob([]); 132 | expect(getSchemaTypeAndDefaultValueFromValue(blob)).toEqual({ 133 | defaultValue: blob, 134 | type: Blob 135 | }) 136 | expect(getSchemaTypeAndDefaultValueFromValue(Blob)).toEqual({ 137 | defaultValue: null, 138 | type: Blob 139 | }) 140 | }); 141 | 142 | it('should handle OneOf', () => { 143 | let res = getSchemaTypeAndDefaultValueFromValue(OneOf([String, Number], "")); 144 | 145 | expect(res.defaultValue).toEqual(""); 146 | expect(res.type?.name).toEqual('OneOf'); 147 | }); 148 | 149 | it('should handle SchemaId', () => { 150 | let res = getSchemaTypeAndDefaultValueFromValue(SchemaId); 151 | 152 | expect(res.defaultValue).toEqual(expect.any(String)); 153 | expect(res.type).toEqual(SchemaId); 154 | expect(res.type?.name).toEqual('SchemaId'); 155 | 156 | res = getSchemaTypeAndDefaultValueFromValue(new SchemaId()); 157 | 158 | expect(res.defaultValue).toEqual(expect.any(String)); 159 | expect(res.type).toEqual(SchemaId); 160 | expect(res.type?.name).toEqual('SchemaId'); 161 | }); 162 | 163 | it('should handle Schema', () => { 164 | const schema = new Schema('test'); 165 | let res = getSchemaTypeAndDefaultValueFromValue(schema); 166 | 167 | expect(res.defaultValue).toEqual({}); 168 | expect(res.type).toEqual(Schema); 169 | 170 | res = getSchemaTypeAndDefaultValueFromValue(Schema); 171 | 172 | expect(res.defaultValue).toEqual(null); 173 | expect(res.type).toEqual(Schema); 174 | }); 175 | 176 | it('should handle Object literal', () => { 177 | let res = getSchemaTypeAndDefaultValueFromValue({}); 178 | 179 | expect(res).toEqual({type: Schema, defaultValue: {}}); 180 | }); 181 | 182 | it('should handle any other', () => { 183 | let res = getSchemaTypeAndDefaultValueFromValue(null); 184 | 185 | expect(res).toEqual({type: null, defaultValue: null}); 186 | }); 187 | }); 188 | -------------------------------------------------------------------------------- /src/utils/get-schema-type-and-default-value-from-value.ts: -------------------------------------------------------------------------------- 1 | import {SchemaJSON, SchemaValueConstructorType, SchemaValueType, SchemaObjectLiteral} from "../types"; 2 | import {ArrayOf} from "../CustomTypes/ArrayOf"; 3 | import {getDefaultValue} from "./get-default-value"; 4 | import {Schema} from "../Schema"; 5 | import {isSupportedType} from "./is-supported-type"; 6 | import {SchemaId} from "../CustomTypes/SchemaId"; 7 | 8 | export const getSchemaTypeAndDefaultValueFromValue = (value: SchemaValueType | SchemaValueConstructorType | SchemaObjectLiteral): { 9 | type: SchemaValueConstructorType | Schema | null, 10 | defaultValue: SchemaValueType | SchemaJSON | SchemaObjectLiteral | undefined 11 | } => { 12 | let type: SchemaValueConstructorType | null = null; 13 | let defaultValue = value as SchemaValueType; 14 | 15 | switch (typeof value) { 16 | case 'string': 17 | type = String; 18 | break; 19 | case 'number': 20 | type = Number; 21 | break; 22 | case 'boolean': 23 | type = Boolean; 24 | break; 25 | case 'object': // handles all non-primitives instances 26 | if (value instanceof Date) { 27 | return {type: Date, defaultValue}; 28 | } 29 | 30 | if (Array.isArray(value)) { 31 | if (value.length) { 32 | const {type: firstItemType} = getSchemaTypeAndDefaultValueFromValue(value[0]); 33 | 34 | if (value.every(item => getSchemaTypeAndDefaultValueFromValue(item).type === firstItemType)) { 35 | return {type: ArrayOf(firstItemType as SchemaValueConstructorType), defaultValue: value}; 36 | } 37 | } 38 | 39 | return {type: Array, defaultValue}; 40 | } 41 | 42 | // handle typed array 43 | if (ArrayBuffer.isView(value)) { 44 | return {type: (value as any).constructor, defaultValue}; 45 | } 46 | 47 | if (value instanceof ArrayBuffer) { 48 | return {type: ArrayBuffer, defaultValue}; 49 | } 50 | 51 | if (value instanceof Blob) { 52 | return {type: Blob, defaultValue}; 53 | } 54 | 55 | if (value instanceof Schema) { 56 | return {type: Schema, defaultValue: value.toValue()}; 57 | } 58 | 59 | if (value instanceof SchemaId) { 60 | return {type: SchemaId, defaultValue: value.defaultValue}; 61 | } 62 | 63 | if (`${value}` === '[object Object]') { 64 | return {type: Schema, defaultValue: value}; 65 | } 66 | 67 | return {type: null, defaultValue}; 68 | default: // handles all constructors 69 | return isSupportedType(value) 70 | ? { 71 | type: value, 72 | defaultValue: getDefaultValue(value) 73 | } 74 | : {type: null, defaultValue} 75 | } 76 | 77 | return {type, defaultValue}; 78 | } 79 | -------------------------------------------------------------------------------- /src/utils/is-empty-string.ts: -------------------------------------------------------------------------------- 1 | export const isEmptyString = (val: any) => (typeof val === "string" || val instanceof String) && val.trim() === ""; 2 | -------------------------------------------------------------------------------- /src/utils/is-nil.ts: -------------------------------------------------------------------------------- 1 | export const isNil = (val: any) => val === null || val === undefined; 2 | -------------------------------------------------------------------------------- /src/utils/is-object-literal.ts: -------------------------------------------------------------------------------- 1 | export const isObjectLiteral = (value: any) => `${value}` === "[object Object]"; 2 | -------------------------------------------------------------------------------- /src/utils/is-of-supported-type.spec.ts: -------------------------------------------------------------------------------- 1 | import {isOfSupportedType} from "./is-of-supported-type"; 2 | import {Schema} from "../Schema"; 3 | import {SchemaValue} from "../SchemaValue"; 4 | import {Null} from "../CustomTypes/Null"; 5 | 6 | describe('isOfType', () => { 7 | it('should always be false for nil and NaN values', () => { 8 | expect(isOfSupportedType(String, null)).toBeFalsy() 9 | expect(isOfSupportedType(Boolean, undefined)).toBeFalsy() 10 | expect(isOfSupportedType(Number, NaN)).toBeFalsy() 11 | }); 12 | 13 | it('should match String', () => { 14 | expect(isOfSupportedType(String, 'sample')).toBeTruthy() 15 | expect(isOfSupportedType(String, ``)).toBeTruthy() 16 | expect(isOfSupportedType(String, "")).toBeTruthy() 17 | expect(isOfSupportedType(String, String("str"))).toBeTruthy() 18 | expect(isOfSupportedType(String, new String("sample"))).toBeTruthy() 19 | }); 20 | 21 | it('should match Number', () => { 22 | expect(isOfSupportedType(Number, Infinity)).toBeTruthy() 23 | expect(isOfSupportedType(Number, 0)).toBeTruthy() 24 | expect(isOfSupportedType(Number, new Number(112))).toBeTruthy() 25 | }); 26 | 27 | it('should match Boolean', () => { 28 | expect(isOfSupportedType(Boolean, false)).toBeTruthy() 29 | expect(isOfSupportedType(Boolean, new Boolean(true))).toBeTruthy() 30 | }); 31 | 32 | it('should match Date', () => { 33 | expect(isOfSupportedType(Date, new Date())).toBeTruthy() 34 | expect(isOfSupportedType(Date, Date.now())).toBeFalsy() 35 | }); 36 | 37 | it('should match Array', () => { 38 | expect(isOfSupportedType(Array, new Array())).toBeTruthy() 39 | expect(isOfSupportedType(Array, [])).toBeTruthy() 40 | expect(isOfSupportedType(Array, new Int32Array())).toBeFalsy() 41 | expect(isOfSupportedType(Array, new ArrayBuffer(8))).toBeFalsy() 42 | }); 43 | 44 | it('should match Typed Array', () => { 45 | expect(isOfSupportedType(Int32Array, new Array())).toBeFalsy() 46 | expect(isOfSupportedType(Int32Array, new Int16Array())).toBeFalsy() 47 | expect(isOfSupportedType(Int32Array, [])).toBeFalsy() 48 | expect(isOfSupportedType(Int32Array, new Int32Array())).toBeTruthy() 49 | expect(isOfSupportedType(Int32Array, new ArrayBuffer(8))).toBeFalsy() 50 | }); 51 | 52 | it('should match Array Buffer', () => { 53 | expect(isOfSupportedType(ArrayBuffer, new Array())).toBeFalsy() 54 | expect(isOfSupportedType(ArrayBuffer, new Int16Array())).toBeFalsy() 55 | expect(isOfSupportedType(ArrayBuffer, [])).toBeFalsy() 56 | expect(isOfSupportedType(ArrayBuffer, new Int32Array())).toBeFalsy() 57 | expect(isOfSupportedType(ArrayBuffer, new ArrayBuffer(8))).toBeTruthy() 58 | }); 59 | 60 | it('should match Blob', () => { 61 | expect(isOfSupportedType(Blob, new Blob())).toBeTruthy() 62 | }); 63 | 64 | it('should match Null', () => { 65 | expect(isOfSupportedType(Null, null)).toBeTruthy() 66 | }); 67 | 68 | it('should match Schema', () => { 69 | const userSchema = new Schema("user", { 70 | name: new SchemaValue(String, true), 71 | avatar: new SchemaValue(String), 72 | }); 73 | 74 | expect(isOfSupportedType(userSchema, Schema)).toBeFalsy() 75 | expect(isOfSupportedType(userSchema, userSchema)).toBeTruthy() 76 | expect(isOfSupportedType(userSchema, { 77 | name: "me", 78 | avatar: "" 79 | })).toBeTruthy() 80 | expect(isOfSupportedType(userSchema, { 81 | name: "me", 82 | avatar: "", 83 | extra: "value" 84 | })).toBeFalsy() 85 | expect(isOfSupportedType(userSchema, {})).toBeFalsy() 86 | }); 87 | 88 | it('should handle error', () => { 89 | // @ts-ignore 90 | expect(isOfSupportedType("", "test")).toBeFalsy() 91 | }); 92 | }) 93 | -------------------------------------------------------------------------------- /src/utils/is-of-supported-type.ts: -------------------------------------------------------------------------------- 1 | import {SchemaValueConstructorType} from "../types"; 2 | import {Schema} from "../Schema"; 3 | import {Null} from "../CustomTypes/Null"; 4 | 5 | export const isOfSupportedType = (type: SchemaValueConstructorType | Schema, value: any) => { 6 | if(type === Null) { 7 | return value === null; 8 | } 9 | 10 | if ((typeof value === "number" && isNaN(value)) || (!value && value === undefined)) { 11 | return false; 12 | } 13 | 14 | try { 15 | if (type instanceof Schema) { 16 | return value === type || `${value}` === '[object Object]' && type.getInvalidSchemaDataFields(value).length === 0; 17 | } 18 | 19 | const typeOf = typeof value === type.name.toLowerCase(); 20 | 21 | // @ts-ignore 22 | return (type[Symbol.hasInstance] ? value instanceof type : typeOf) || typeOf; 23 | } catch (e) { 24 | // likely errors: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Errors/invalid_right_hand_side_instanceof_operand#examples 25 | return false 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/utils/is-same-value-type.spec.ts: -------------------------------------------------------------------------------- 1 | import {isSameValueType} from "./is-same-value-type"; 2 | import {ArrayOf} from "../CustomTypes/ArrayOf"; 3 | import {OneOf} from "../CustomTypes/OneOf"; 4 | import {SchemaId} from "../CustomTypes/SchemaId"; 5 | import {Schema} from "../Schema"; 6 | import {Null} from "../CustomTypes/Null"; 7 | 8 | describe("isSameValueType", () => { 9 | it('should handle Array', () => { 10 | expect(isSameValueType(Array, [undefined])).toBeFalsy() 11 | }); 12 | 13 | it('should handle ArrayOf', () => { 14 | expect(isSameValueType(ArrayOf(String), [12])).toBeFalsy() 15 | expect(isSameValueType(ArrayOf(String), ["str"])).toBeTruthy() 16 | }); 17 | 18 | it('should handle OneOf', () => { 19 | expect(isSameValueType(OneOf([String, Number], ""), null)).toBeFalsy() 20 | expect(isSameValueType(OneOf([String, Number], ""), 12)).toBeTruthy() 21 | expect(isSameValueType(OneOf([String, Number], ""), "str")).toBeTruthy() 22 | }); 23 | 24 | it('should handle SchemaId', () => { 25 | expect(isSameValueType(SchemaId, null)).toBeFalsy() 26 | expect(isSameValueType(SchemaId, "ddd")).toBeFalsy() 27 | expect(isSameValueType(SchemaId, new SchemaId())).toBeTruthy() 28 | expect(isSameValueType(SchemaId, new SchemaId().defaultValue)).toBeTruthy() 29 | }); 30 | 31 | it('should handle Schema', () => { 32 | expect(isSameValueType(Schema, null)).toBeFalsy() 33 | expect(isSameValueType(Schema, {})).toBeTruthy() 34 | }); 35 | 36 | it('should handle Null', () => { 37 | expect(isSameValueType(Null, null)).toBeTruthy() 38 | expect(isSameValueType(Null, {})).toBeFalsy() 39 | expect(isSameValueType(Null, undefined)).toBeFalsy() 40 | }); 41 | 42 | it('should handle error', () => { 43 | // @ts-ignore 44 | expect(isSameValueType({name: "OneOf"}, "1")).toBeFalsy() 45 | }); 46 | }) 47 | -------------------------------------------------------------------------------- /src/utils/is-same-value-type.ts: -------------------------------------------------------------------------------- 1 | import {SchemaValueConstructorType} from "../types"; 2 | import {Schema} from "../Schema"; 3 | import {isSupportedTypeValue} from "./is-supported-type-value"; 4 | import {isOfSupportedType} from "./is-of-supported-type"; 5 | import {SchemaId} from "../CustomTypes/SchemaId"; 6 | 7 | export const isSameValueType = (type: SchemaValueConstructorType | Schema, value: any): boolean => { 8 | try { 9 | if (value instanceof Array && value.some(v => !isSupportedTypeValue(v))) { 10 | return false; 11 | } 12 | 13 | if (/ArrayOf/.test(type?.name)) { 14 | const Type = (new (type as any)()); 15 | 16 | return value instanceof Array && !value.some(v => !isSameValueType(Type.type, v)) 17 | } 18 | 19 | if (/OneOf/.test(type?.name)) { 20 | return new (type as any)().type.some((t: SchemaValueConstructorType | Schema) => isSameValueType(t, value)) 21 | } 22 | 23 | if (/SchemaId/.test(type?.name)) { 24 | return value instanceof SchemaId || (typeof value === 'string' && /[0-9a-z]{8}-[0-9a-z]{4}-4[0-9a-z]{3}-[0-9a-z]{4}-[0-9a-z]{12}/.test(value)) 25 | } 26 | 27 | if (/Schema/.test(type?.name)) { 28 | return `${value}` === '[object Object]'; 29 | } 30 | 31 | if (/Null/.test(type?.name)) { 32 | return value === null; 33 | } 34 | 35 | return isOfSupportedType(type, value); 36 | } catch (e) { 37 | return false 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/utils/is-supported-type-value.ts: -------------------------------------------------------------------------------- 1 | import {Schema} from "../Schema"; 2 | import {SchemaId} from "../CustomTypes/SchemaId"; 3 | import {SchemaValueType} from "../types"; 4 | 5 | export const isSupportedTypeValue = (value: SchemaValueType): boolean => { 6 | return value === null || [ 7 | Schema, 8 | SchemaId, 9 | Date, 10 | Number, 11 | String, 12 | Boolean, 13 | Array, 14 | ArrayBuffer, 15 | Blob, 16 | Float32Array, 17 | Float64Array, 18 | Int8Array, 19 | Int16Array, 20 | Int32Array, 21 | Uint8Array, 22 | Uint8ClampedArray, 23 | Uint16Array, 24 | Uint32Array, 25 | Object 26 | ].some(type => { 27 | return value instanceof type || typeof value === type.name.toLowerCase() 28 | }) 29 | } 30 | -------------------------------------------------------------------------------- /src/utils/is-supported-type.ts: -------------------------------------------------------------------------------- 1 | import {Schema} from "../Schema"; 2 | import {SchemaId} from "../CustomTypes/SchemaId"; 3 | import {SchemaValueConstructorType} from "../types"; 4 | import {CustomType} from "../CustomTypes/CustomType"; 5 | 6 | export const isSupportedType = (value: SchemaValueConstructorType): boolean => { 7 | const valueTypeName = ((value || {name: ''}) as unknown as CustomType).name; 8 | 9 | return /ArrayOf|OneOf|Null/.test(valueTypeName) || [ 10 | Schema, 11 | SchemaId, 12 | Date, 13 | Number, 14 | String, 15 | Boolean, 16 | Array, 17 | ArrayBuffer, 18 | Blob, 19 | Float32Array, 20 | Float64Array, 21 | Int8Array, 22 | Int16Array, 23 | Int32Array, 24 | Uint8Array, 25 | Uint8ClampedArray, 26 | Uint16Array, 27 | Uint32Array 28 | ].some(type => { 29 | return value === type; 30 | }) 31 | } 32 | -------------------------------------------------------------------------------- /src/utils/is-valid-object-literal.ts: -------------------------------------------------------------------------------- 1 | import {isObjectLiteral} from "./is-object-literal"; 2 | 3 | export const isValidObjectLiteral = (value: any) => isObjectLiteral(value) && Object.keys(value).length 4 | -------------------------------------------------------------------------------- /src/utils/object-to-schema.spec.ts: -------------------------------------------------------------------------------- 1 | import {objectToSchema} from "./object-to-schema"; 2 | import {ArrayOf} from "../CustomTypes/ArrayOf"; 3 | import {SchemaId} from "../CustomTypes/SchemaId"; 4 | import {Schema} from "../Schema"; 5 | import {SchemaValue} from "../SchemaValue"; 6 | import {OneOf} from "../CustomTypes/OneOf"; 7 | 8 | describe('objectToSchema', () => { 9 | it('should handle primitives and date', () => { 10 | const schema = objectToSchema('test', { 11 | $name: 'John Doe', 12 | description: String, 13 | visible: true, 14 | deleted: Boolean, 15 | yearBorn: 1991, 16 | age: Number, 17 | createdDate: new Date(), 18 | updatedDate: Date, 19 | }); 20 | 21 | expect(schema.toValue()).toEqual({ 22 | "age": 0, 23 | "createdDate": expect.any(Date), 24 | "deleted": false, 25 | "description": "", 26 | "name": "John Doe", 27 | "updatedDate": expect.any(Date), 28 | "visible": true, 29 | "yearBorn": 1991 30 | }) 31 | expect(schema.getField('age')).toEqual({"defaultValue": 0, "required": false, "type": Number}) 32 | expect(schema.getField('createdDate')).toEqual({"defaultValue": expect.any(Date), "required": false, "type": Date}) 33 | expect(schema.getField('deleted')).toEqual({"defaultValue": false, "required": false, "type": Boolean}) 34 | expect(schema.getField('description')).toEqual({"defaultValue": '', "required": false, "type": String}) 35 | expect(schema.getField('name')).toEqual({"defaultValue": 'John Doe', "required": true, "type": String}) 36 | expect(schema.getField('updatedDate')).toEqual({"defaultValue": null, "required": false, "type": Date}) 37 | expect(schema.getField('visible')).toEqual({"defaultValue": true, "required": false, "type": Boolean}) 38 | expect(schema.getField('yearBorn')).toEqual({"defaultValue": 1991, "required": false, "type": Number}) 39 | }); 40 | 41 | it('should handle arrays and Blob', () => { 42 | const schema = objectToSchema('test', { 43 | $items: [], 44 | list: Array, 45 | numbers: [12, 34, 56], 46 | pair: ['key', 3000], 47 | names: ArrayOf(String), 48 | int: new Int16Array(), 49 | buffer: new ArrayBuffer(16), 50 | image: new Blob(), 51 | thumbnail: Blob 52 | }); 53 | 54 | expect(schema.toValue()).toEqual({ 55 | "buffer": expect.any(ArrayBuffer), 56 | "image": expect.any(Blob), 57 | "int": expect.any(Int16Array), 58 | "items": [], 59 | "list": [], 60 | "names": [], 61 | "numbers": [ 62 | 12, 63 | 34, 64 | 56 65 | ], 66 | "pair": [ 67 | "key", 68 | 3000 69 | ], 70 | "thumbnail": null 71 | }) 72 | expect(schema.getField('buffer')).toEqual({ 73 | "defaultValue": expect.any(ArrayBuffer), 74 | "required": false, 75 | "type": ArrayBuffer 76 | }) 77 | expect(schema.getField('int')).toEqual({ 78 | "defaultValue": expect.any(Int16Array), 79 | "required": false, 80 | "type": Int16Array 81 | }) 82 | expect(schema.getField('items')).toEqual({"defaultValue": [], "required": true, "type": Array}) 83 | expect(schema.getField('list')).toEqual({"defaultValue": [], "required": false, "type": Array}) 84 | expect(schema.getField('pair')).toEqual({"defaultValue": ['key', 3000], "required": false, "type": Array}) 85 | expect(schema.getField('image')).toEqual({"defaultValue": expect.any(Blob), "required": false, "type": Blob}) 86 | expect(schema.getField('thumbnail')).toEqual({"defaultValue": null, "required": false, "type": Blob}) 87 | 88 | const nameField = schema.getField('names'); 89 | const numbersField = schema.getField('numbers'); 90 | 91 | expect(nameField?.defaultValue).toEqual([]) 92 | expect(nameField?.required).toEqual(false) 93 | expect(nameField?.type.name).toEqual('ArrayOf'); 94 | expect(schema.isValidFieldValue('names', [12])).toBeFalsy() 95 | expect(schema.isValidFieldValue('names', ['str'])).toBeTruthy() 96 | 97 | expect(numbersField?.defaultValue).toEqual([12, 34, 56]) 98 | expect(numbersField?.required).toEqual(false) 99 | expect(numbersField?.type.name).toEqual('ArrayOf'); 100 | expect(schema.isValidFieldValue('numbers', [12])).toBeTruthy() 101 | expect(schema.isValidFieldValue('numbers', ['str'])).toBeFalsy() 102 | }); 103 | 104 | it('should handle custom types', () => { 105 | const schema = objectToSchema('test', { 106 | $id: SchemaId, 107 | user: { 108 | $id: SchemaId, 109 | name: String 110 | }, 111 | items: new Schema('items', { 112 | id: new SchemaValue(SchemaId, true), 113 | name: new SchemaValue(String) 114 | }), 115 | status: OneOf([String, Number], ""), 116 | names: ArrayOf(String) 117 | }); 118 | 119 | expect(schema.toValue()).toEqual({ 120 | id: expect.any(String), 121 | items: { 122 | id: expect.any(String), 123 | name: '' 124 | }, 125 | names: [], 126 | status: "", 127 | user: { 128 | id: expect.any(String), 129 | name: '' 130 | } 131 | }) 132 | }); 133 | 134 | it('should throw if invalid object is provided', () => { 135 | expect(() => objectToSchema("sample", {})).toThrowError('Invalid "Schema" instance or object') 136 | // @ts-ignore 137 | expect(() => objectToSchema("sample", null)).toThrowError('Invalid "Schema" instance or object') 138 | }); 139 | }) 140 | -------------------------------------------------------------------------------- /src/utils/object-to-schema.ts: -------------------------------------------------------------------------------- 1 | import {Schema} from "../Schema"; 2 | import {getSchemaTypeAndDefaultValueFromValue} from "./get-schema-type-and-default-value-from-value"; 3 | import {SchemaObjectLiteral} from "../types"; 4 | import {isValidObjectLiteral} from "../utils/is-valid-object-literal"; 5 | import {errorMessages} from "../utils/error-messages"; 6 | 7 | export const objectToSchema = (name: string, schemaData: SchemaObjectLiteral): Schema => { 8 | if (!isValidObjectLiteral(schemaData)) { 9 | throw new Error(errorMessages.invalidSchema()) 10 | } 11 | 12 | const schema = new Schema(name); 13 | 14 | Object.entries(schemaData) 15 | .forEach(([key, val]) => { 16 | key = `${key}`; 17 | const required = key.startsWith('$'); 18 | 19 | if (required) { 20 | key = `${key}`.slice(1); 21 | } 22 | 23 | let {type, defaultValue} = getSchemaTypeAndDefaultValueFromValue(val) 24 | 25 | if (!type) { 26 | throw new Error(`Unsupported Schema Type => key: "${key}", type: "${val?.constructor?.name ?? typeof val}" (estimated)`) 27 | } 28 | 29 | if (type === Schema && `${defaultValue}` === '[object Object]') { 30 | type = objectToSchema(key, defaultValue as SchemaObjectLiteral); 31 | defaultValue = undefined 32 | } 33 | 34 | // @ts-ignore 35 | schema.defineField(key, type, { 36 | required, 37 | ...(defaultValue === null ? {} : {defaultValue}) 38 | }) 39 | }); 40 | 41 | return schema; 42 | } 43 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "ts-node": { 3 | "compilerOptions": { 4 | "module": "commonjs" 5 | } 6 | }, 7 | "compilerOptions": { 8 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 9 | 10 | /* Basic Options */ 11 | // "incremental": true, /* Enable incremental compilation */ 12 | "target": "ES2022", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', 'ES2021', or 'ESNEXT'. */ 13 | "module": "ESNext", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ 14 | "lib": [ 15 | "dom", 16 | "es2015.proxy", 17 | ], /* Specify library files to be included in the compilation. */ 18 | // "allowJs": true, /* Allow javascript files to be compiled. */ 19 | // "checkJs": true, /* Report errors in .js files. */ 20 | "jsx": "react-jsx", /* Specify JSX code generation: 'preserve', 'react-native', 'react', 'react-jsx' or 'react-jsxdev'. */ 21 | "declaration": true, /* Generates corresponding '.d.ts' file. */ 22 | "declarationMap": false, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 23 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 24 | // "outFile": "./", /* Concatenate and emit output to single file. */ 25 | "outDir": "./dist", /* Redirect output structure to the directory. */ 26 | "rootDir": "./src", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 27 | // "composite": true, /* Enable project compilation */ 28 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 29 | "removeComments": true, /* Do not emit comments to output. */ 30 | // "noEmit": true, /* Do not emit outputs. */ 31 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 32 | "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 33 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 34 | 35 | /* Strict Type-Checking Options */ 36 | "strict": true, /* Enable all strict type-checking options. */ 37 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 38 | // "strictNullChecks": true, /* Enable strict null checks. */ 39 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 40 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 41 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 42 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 43 | "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 44 | 45 | /* Additional Checks */ 46 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 47 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 48 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 49 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 50 | // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ 51 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an 'override' modifier. */ 52 | // "noPropertyAccessFromIndexSignature": true, /* Require undeclared properties from index signatures to use element accesses. */ 53 | 54 | /* Module Resolution Options */ 55 | "resolveJsonModule": true, 56 | "moduleResolution": "NodeNext", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 57 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 58 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 59 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 60 | // "typeRoots": [], /* List of folders to include type definitions from. */ 61 | // "types": [], /* Type declaration files to be included in compilation. */ 62 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 63 | "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 64 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 65 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 66 | 67 | /* Source Map Options */ 68 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 69 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 70 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 71 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 72 | 73 | /* Experimental Options */ 74 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 75 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 76 | 77 | /* Advanced Options */ 78 | "skipLibCheck": true, /* Skip type checking of declaration files. */ 79 | "forceConsistentCasingInFileNames": true , /* Disallow inconsistently-cased references to the same file. */ 80 | }, 81 | "include": [ 82 | "./src/**/*" 83 | ], 84 | "exclude": [ 85 | "src/**/*.spec.ts", 86 | "node_modules" 87 | ] 88 | } 89 | --------------------------------------------------------------------------------