├── .gitattributes ├── .gitignore ├── jest.config.js ├── tsconfig.types.json ├── .editorconfig ├── babel.config.json ├── src ├── utils │ ├── Disposable.ts │ ├── Unsubscribable.ts │ ├── invariant.ts │ ├── cache.ts │ └── data.ts ├── language │ ├── serializer.ts │ ├── tag.ts │ ├── utils.ts │ ├── ast.ts │ ├── parser.ts │ └── parser.spec.ts ├── index.ts ├── __tests__ │ ├── set.spec.ts │ ├── restore.spec.ts │ ├── identify.spec.ts │ ├── delete.spec.ts │ ├── expiration.spec.ts │ ├── gc.spec.ts │ ├── watch.spec.ts │ ├── invalidation.spec.ts │ ├── optimistic.spec.ts │ ├── validation.spec.ts │ ├── read.spec.ts │ └── write.spec.ts ├── types.ts ├── operations │ ├── delete.ts │ ├── invalidate.ts │ ├── modify.ts │ ├── shared.ts │ ├── read.ts │ └── write.ts ├── schema │ ├── utils.ts │ └── types.ts └── Cache.ts ├── tsconfig.json ├── .eslintrc.js ├── rollup.config.js ├── package.json ├── docs ├── CQL.md ├── Integration.md └── Schema.md └── README.md /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist/** 2 | node_modules/ 3 | npm-debug.log* 4 | npm-error.log* 5 | .npm 6 | .DS_Store 7 | Thumbs.db 8 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line no-undef 2 | module.exports = { 3 | preset: "ts-jest", 4 | testEnvironment: "node", 5 | globals: { 6 | "ts-jest": { 7 | diagnostics: false, 8 | }, 9 | }, 10 | }; 11 | -------------------------------------------------------------------------------- /tsconfig.types.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["./src/index.ts"], 4 | "compilerOptions": { 5 | "declaration": true, 6 | "emitDeclarationOnly": true, 7 | "noEmit": false, 8 | "outDir": "./dist/types" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /babel.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/env", 5 | { 6 | "loose": true, 7 | "targets": { 8 | "ie": "11" 9 | } 10 | } 11 | ], 12 | "@babel/preset-typescript" 13 | ], 14 | "plugins": [ 15 | ["@babel/plugin-transform-for-of", { 16 | "assumeArray": true 17 | }] 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /src/utils/Disposable.ts: -------------------------------------------------------------------------------- 1 | export class Disposable { 2 | disposed?: boolean; 3 | disposeFn: () => void; 4 | 5 | constructor(disposeFn: () => void) { 6 | this.disposeFn = disposeFn; 7 | this.dispose = this.dispose.bind(this); 8 | } 9 | 10 | dispose(): void { 11 | if (!this.disposed) { 12 | this.disposed = true; 13 | this.disposeFn(); 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/language/serializer.ts: -------------------------------------------------------------------------------- 1 | import type { DocumentNode } from "./ast"; 2 | 3 | export function serializeSelector(selector: DocumentNode): string; 4 | // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types 5 | export function serializeSelector(selector: any): string { 6 | if (!selector._id) { 7 | selector._id = JSON.stringify(selector); 8 | } 9 | return selector._id; 10 | } 11 | -------------------------------------------------------------------------------- /src/utils/Unsubscribable.ts: -------------------------------------------------------------------------------- 1 | export class Unsubscribable { 2 | unsubscribed?: boolean; 3 | unsubscribeFn: () => void; 4 | 5 | constructor(unsubscribeFn: () => void) { 6 | this.unsubscribeFn = unsubscribeFn; 7 | this.unsubscribe = this.unsubscribe.bind(this); 8 | } 9 | 10 | unsubscribe(): void { 11 | if (!this.unsubscribed) { 12 | this.unsubscribed = true; 13 | this.unsubscribeFn(); 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["./src"], 3 | "compilerOptions": { 4 | "allowSyntheticDefaultImports": true, 5 | "esModuleInterop": true, 6 | "isolatedModules": true, 7 | "lib": ["es2017"], 8 | "module": "es2015", 9 | "moduleResolution": "node", 10 | "noEmit": true, 11 | "noUnusedLocals": true, 12 | "noUnusedParameters": true, 13 | "rootDir": "./src", 14 | "skipLibCheck": true, 15 | "sourceMap": true, 16 | "strict": true, 17 | "target": "es5" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { cql } from "./language/tag"; 2 | export { Cache } from "./Cache"; 3 | export { schema } from "./schema/types"; 4 | 5 | export type { 6 | CacheConfig, 7 | DeleteOptions, 8 | IdentifyOptions, 9 | InvalidateOptions, 10 | ReadOptions, 11 | WatchOptions, 12 | WriteOptions, 13 | } from "./Cache"; 14 | export type { DeleteResult } from "./operations/delete"; 15 | export type { InvalidateResult } from "./operations/invalidate"; 16 | export type { ReadResult } from "./operations/read"; 17 | export type { WriteResult } from "./operations/write"; 18 | -------------------------------------------------------------------------------- /src/utils/invariant.ts: -------------------------------------------------------------------------------- 1 | export const ErrorCode = { 2 | TYPE_NOT_FOUND: 1, 3 | UNABLE_TO_INFER_ENTITY_ID: 2, 4 | TYPE_CHECK: 3, 5 | INVALID_SELECTOR: 4, 6 | INVALID_CONST: 5, 7 | SELECTOR_SCHEMA_MISMATCH: 6, 8 | WRITE_CIRCULAR_DATA: 7, 9 | }; 10 | 11 | export function invariant( 12 | condition: unknown, 13 | msgOrCode: string | number 14 | ): asserts condition { 15 | if (!condition) { 16 | if (typeof msgOrCode === "number") { 17 | msgOrCode = "Minified Error #" + msgOrCode; 18 | } 19 | const error = new Error(msgOrCode); 20 | error.name = "Cache Error"; 21 | throw error; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/__tests__/set.spec.ts: -------------------------------------------------------------------------------- 1 | import { Cache, schema } from ".."; 2 | 3 | describe("set", () => { 4 | it("should be able to manually change the invalidation state", () => { 5 | const Type = schema.string({ name: "Type" }); 6 | const cache = new Cache({ types: [Type] }); 7 | cache.write({ type: "Type", data: "a" }); 8 | const result1 = cache.read({ type: "Type" }); 9 | const entity = cache.get("Type"); 10 | if (entity) { 11 | cache.set(entity.id, { ...entity, invalidated: true }); 12 | } 13 | const result2 = cache.read({ type: "Type" }); 14 | expect(result1!.stale).toBe(false); 15 | expect(result2!.stale).toBe(true); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line no-undef 2 | module.exports = { 3 | env: { 4 | browser: true, 5 | es2021: true, 6 | }, 7 | extends: [ 8 | "eslint:recommended", 9 | "plugin:@typescript-eslint/recommended", 10 | "plugin:import/typescript", 11 | ], 12 | parser: "@typescript-eslint/parser", 13 | parserOptions: { 14 | ecmaVersion: 12, 15 | sourceType: "module", 16 | }, 17 | plugins: ["@typescript-eslint", "import"], 18 | rules: { 19 | "@typescript-eslint/no-empty-interface": "off", 20 | "@typescript-eslint/no-explicit-any": "off", 21 | "@typescript-eslint/no-non-null-assertion": "off", 22 | "@typescript-eslint/no-shadow": "error", 23 | "import/no-cycle": "error", 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export interface PlainObject { 2 | ___dummy?: unknown; 3 | [key: string]: unknown; 4 | } 5 | 6 | export interface PlainObjectWithMeta { 7 | ___expiresAt: Record; 8 | ___invalidated: Record; 9 | [key: string]: unknown; 10 | } 11 | 12 | export interface Entity { 13 | expiresAt: number; 14 | id: string; 15 | invalidated: boolean; 16 | value: unknown; 17 | } 18 | 19 | export interface Reference { 20 | ___ref: string; 21 | } 22 | 23 | export type EntitiesRecord = Record; 24 | 25 | export interface MissingField { 26 | path: (string | number)[]; 27 | } 28 | 29 | export interface InvalidField { 30 | value: any; 31 | path: (string | number)[]; 32 | } 33 | -------------------------------------------------------------------------------- /src/language/tag.ts: -------------------------------------------------------------------------------- 1 | import { DocumentNode, NodeType } from "./ast"; 2 | import { parse } from "./parser"; 3 | 4 | const CACHE: Record = {}; 5 | 6 | export function cql( 7 | strings: TemplateStringsArray, 8 | ...values: Array 9 | ): DocumentNode { 10 | let result = ``; 11 | 12 | strings.forEach((str, index) => { 13 | let value = index <= values.length - 1 ? values[index] : ``; 14 | 15 | if ( 16 | typeof value !== "string" && 17 | value.kind === NodeType.Document && 18 | value.src 19 | ) { 20 | value = value.src; 21 | } 22 | 23 | result += str + value; 24 | }); 25 | 26 | const src = result.trim(); 27 | 28 | if (!CACHE[src]) { 29 | CACHE[src] = parse(src); 30 | } 31 | 32 | return CACHE[src]; 33 | } 34 | -------------------------------------------------------------------------------- /src/language/utils.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DocumentNode, 3 | FieldNode, 4 | InlineFragmentNode, 5 | NodeType, 6 | SelectionSetNode, 7 | } from "./ast"; 8 | 9 | export function createDocument(): DocumentNode { 10 | return { 11 | kind: NodeType.Document, 12 | definitions: [], 13 | }; 14 | } 15 | 16 | export function createField(fieldName: string): FieldNode { 17 | return { 18 | kind: NodeType.Field, 19 | name: { 20 | kind: NodeType.Name, 21 | value: fieldName, 22 | }, 23 | }; 24 | } 25 | 26 | export function createSelectionSet(): SelectionSetNode { 27 | return { kind: NodeType.SelectionSet, selections: [] }; 28 | } 29 | 30 | export function createInlineFragment(typeName: string): InlineFragmentNode { 31 | return { 32 | kind: NodeType.InlineFragment, 33 | selectionSet: createSelectionSet(), 34 | typeCondition: { 35 | kind: NodeType.NamedType, 36 | name: { 37 | kind: NodeType.Name, 38 | value: typeName, 39 | }, 40 | }, 41 | }; 42 | } 43 | -------------------------------------------------------------------------------- /src/operations/delete.ts: -------------------------------------------------------------------------------- 1 | import type { ValueType } from "../schema/types"; 2 | import type { Cache } from "../Cache"; 3 | import type { DocumentNode } from "../language/ast"; 4 | import { executeModify } from "./modify"; 5 | import { resolveEntity } from "../utils/cache"; 6 | 7 | interface DeleteOptions { 8 | id?: unknown; 9 | select?: DocumentNode; 10 | } 11 | 12 | export interface DeleteResult { 13 | updatedEntityIDs?: string[]; 14 | } 15 | 16 | export function executeDelete( 17 | cache: Cache, 18 | type: ValueType, 19 | optimistic: boolean, 20 | options: DeleteOptions 21 | ): DeleteResult | undefined { 22 | const entity = resolveEntity(cache, type, options.id, optimistic); 23 | 24 | if (!entity) { 25 | return; 26 | } 27 | 28 | return executeModify(cache, type, optimistic, { 29 | entityID: entity.id, 30 | selector: options.select, 31 | onEntity: (ctx, visitedEntity, selectionSet) => { 32 | if (!selectionSet) { 33 | ctx.entities[visitedEntity.id] = undefined; 34 | return false; 35 | } 36 | }, 37 | onField: (_ctx, parent, field) => { 38 | if (!field.selectionSet) { 39 | delete parent[field.name.value]; 40 | return false; 41 | } 42 | }, 43 | }); 44 | } 45 | -------------------------------------------------------------------------------- /src/operations/invalidate.ts: -------------------------------------------------------------------------------- 1 | import type { ValueType } from "../schema/types"; 2 | import type { Cache } from "../Cache"; 3 | import type { DocumentNode } from "../language/ast"; 4 | import { isObjectWithMeta, resolveEntity } from "../utils/cache"; 5 | import { executeModify } from "./modify"; 6 | 7 | interface InvalidateOptions { 8 | id?: unknown; 9 | select?: DocumentNode; 10 | } 11 | 12 | export interface InvalidateResult { 13 | updatedEntityIDs?: string[]; 14 | } 15 | 16 | export function executeInvalidate( 17 | cache: Cache, 18 | type: ValueType, 19 | optimistic: boolean, 20 | options: InvalidateOptions 21 | ): InvalidateResult | undefined { 22 | const entity = resolveEntity(cache, type, options.id, optimistic); 23 | 24 | if (!entity) { 25 | return; 26 | } 27 | 28 | return executeModify(cache, type, optimistic, { 29 | entityID: entity.id, 30 | selector: options.select, 31 | onEntity: (_ctx, visitedEntity, selectionSet) => { 32 | if (!selectionSet) { 33 | visitedEntity.invalidated = true; 34 | return false; 35 | } 36 | }, 37 | onField: (_ctx, parent, field) => { 38 | if (!field.selectionSet && isObjectWithMeta(parent)) { 39 | parent.___invalidated[field.name.value] = true; 40 | return false; 41 | } 42 | }, 43 | }); 44 | } 45 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import { babel } from "@rollup/plugin-babel"; 2 | import { nodeResolve } from "@rollup/plugin-node-resolve"; 3 | import replace from "@rollup/plugin-replace"; 4 | import filesize from "rollup-plugin-filesize"; 5 | import { terser } from "rollup-plugin-terser"; 6 | 7 | const resolveConfig = { extensions: [".ts"] }; 8 | const babelConfig = { extensions: [".ts"], babelHelpers: "bundled" }; 9 | 10 | export default [ 11 | { 12 | input: "./src/index.ts", 13 | output: { 14 | file: "./dist/index.min.js", 15 | sourcemap: true, 16 | format: "cjs", 17 | }, 18 | plugins: [ 19 | nodeResolve(resolveConfig), 20 | babel(babelConfig), 21 | replace({ 22 | values: { 23 | "process.env.NODE_ENV": JSON.stringify("production"), 24 | }, 25 | preventAssignment: true, 26 | }), 27 | terser(), 28 | filesize(), 29 | ], 30 | }, 31 | { 32 | input: "./src/index.ts", 33 | output: { 34 | file: "./dist/index.js", 35 | sourcemap: true, 36 | format: "cjs", 37 | }, 38 | plugins: [nodeResolve(resolveConfig), babel(babelConfig), filesize()], 39 | }, 40 | { 41 | input: "./src/index.ts", 42 | output: { 43 | file: "./dist/index.es.js", 44 | sourcemap: true, 45 | format: "es", 46 | }, 47 | plugins: [nodeResolve(resolveConfig), babel(babelConfig)], 48 | }, 49 | ]; 50 | -------------------------------------------------------------------------------- /src/__tests__/restore.spec.ts: -------------------------------------------------------------------------------- 1 | import { Cache, schema } from ".."; 2 | 3 | describe("restore", () => { 4 | it("should be able to extract and restore the cache", () => { 5 | const Type = schema.object({ name: "Type" }); 6 | const cache = new Cache({ types: [Type] }); 7 | cache.write({ type: "Type", data: { id: "1", a: "a" } }); 8 | cache.write({ type: "Type", data: { id: "1", a: "b" }, optimistic: true }); 9 | const data = cache.extract(); 10 | const cache2 = new Cache({ types: [Type] }); 11 | cache2.restore(data); 12 | const result1 = cache2.read({ type: "Type", id: "1" }); 13 | const result2 = cache2.read({ type: "Type", id: "1", optimistic: false }); 14 | expect(result1!.data).toEqual({ id: "1", a: "a" }); 15 | expect(result2!.data).toEqual({ id: "1", a: "a" }); 16 | }); 17 | 18 | it("should be able to extract and restore the cache including optimistic data", () => { 19 | const Type = schema.object({ name: "Type" }); 20 | const cache = new Cache({ types: [Type] }); 21 | cache.write({ type: "Type", data: { id: "1", a: "a" } }); 22 | cache.write({ type: "Type", data: { id: "1", a: "b" }, optimistic: true }); 23 | const data = cache.extract(true); 24 | const cache2 = new Cache({ types: [Type] }); 25 | cache2.restore(data); 26 | const result1 = cache2.read({ type: "Type", id: "1" }); 27 | const result2 = cache2.read({ type: "Type", id: "1", optimistic: false }); 28 | expect(result1!.data).toEqual({ id: "1", a: "b" }); 29 | expect(result2!.data).toEqual({ id: "1", a: "a" }); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /src/language/ast.ts: -------------------------------------------------------------------------------- 1 | export const NodeType = { 2 | Document: "Document", 3 | Field: "Field", 4 | FragmentDefinition: "FragmentDefinition", 5 | FragmentSpread: "FragmentSpread", 6 | InlineFragment: "InlineFragment", 7 | Name: "Name", 8 | NamedType: "NamedType", 9 | SelectionSet: "SelectionSet", 10 | Star: "Star", 11 | } as const; 12 | 13 | export interface DocumentNode { 14 | kind: "Document"; 15 | definitions: DefinitionNode[]; 16 | src?: string; 17 | } 18 | 19 | export type DefinitionNode = FragmentDefinitionNode | SelectionSetNode; 20 | 21 | export interface FragmentDefinitionNode { 22 | kind: "FragmentDefinition"; 23 | name: NameNode; 24 | typeCondition: NamedTypeNode; 25 | selectionSet: SelectionSetNode; 26 | } 27 | 28 | export interface FragmentSpreadNode { 29 | kind: "FragmentSpread"; 30 | name: NameNode; 31 | } 32 | 33 | export interface SelectionSetNode { 34 | kind: "SelectionSet"; 35 | selections: SelectionNode[]; 36 | } 37 | 38 | export type SelectionNode = 39 | | FieldNode 40 | | FragmentSpreadNode 41 | | InlineFragmentNode 42 | | StarNode; 43 | 44 | export interface StarNode { 45 | kind: "Star"; 46 | } 47 | 48 | export interface FieldNode { 49 | kind: "Field"; 50 | alias?: NameNode; 51 | name: NameNode; 52 | selectionSet?: SelectionSetNode; 53 | } 54 | 55 | export interface NameNode { 56 | kind: "Name"; 57 | value: string; 58 | } 59 | 60 | export interface NamedTypeNode { 61 | kind: "NamedType"; 62 | name: NameNode; 63 | } 64 | 65 | export interface InlineFragmentNode { 66 | kind: "InlineFragment"; 67 | typeCondition?: NamedTypeNode; 68 | selectionSet: SelectionSetNode; 69 | } 70 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "normalized-cache", 3 | "version": "0.0.3", 4 | "description": "A cache for storing normalized data.", 5 | "keywords": [ 6 | "cache", 7 | "store", 8 | "normalized", 9 | "denormalized", 10 | "normalization", 11 | "denormalization", 12 | "reactive", 13 | "graph", 14 | "query", 15 | "data" 16 | ], 17 | "main": "./dist/index.js", 18 | "types": "./dist/types/index.d.ts", 19 | "module": "./dist/index.es.js", 20 | "files": [ 21 | "dist" 22 | ], 23 | "sideEffects": false, 24 | "scripts": { 25 | "build": "rm -rf ./dist && rollup -c && tsc --project tsconfig.types.json", 26 | "test": "npm run test:tsc && npm run test:unit && npm run test:lint", 27 | "test:tsc": "tsc", 28 | "test:unit": "jest", 29 | "test:lint": "eslint ./src" 30 | }, 31 | "repository": { 32 | "type": "git", 33 | "url": "git+https://github.com/boschni/normalized-cache.git" 34 | }, 35 | "author": "Niek Bosch ", 36 | "license": "ISC", 37 | "devDependencies": { 38 | "@babel/cli": "^7.13.0", 39 | "@babel/core": "^7.13.8", 40 | "@babel/preset-env": "^7.13.8", 41 | "@babel/preset-typescript": "^7.13.0", 42 | "@rollup/plugin-babel": "^5.3.0", 43 | "@rollup/plugin-node-resolve": "^11.2.0", 44 | "@rollup/plugin-replace": "^2.4.1", 45 | "@types/jest": "^26.0.20", 46 | "@typescript-eslint/eslint-plugin": "^4.15.2", 47 | "@typescript-eslint/parser": "^4.15.2", 48 | "eslint": "^7.20.0", 49 | "eslint-plugin-import": "^2.22.1", 50 | "jest": "^26.6.3", 51 | "prettier": "^2.2.1", 52 | "rollup": "^2.39.1", 53 | "rollup-plugin-filesize": "^9.1.0", 54 | "rollup-plugin-terser": "^7.0.2", 55 | "ts-jest": "^26.5.1", 56 | "typescript": "^4.1.5" 57 | }, 58 | "dependencies": {} 59 | } 60 | -------------------------------------------------------------------------------- /src/schema/utils.ts: -------------------------------------------------------------------------------- 1 | import { 2 | isArrayType, 3 | isNonNullableType, 4 | isObjectType, 5 | isUnionType, 6 | ObjectFieldType, 7 | ValueType, 8 | } from "./types"; 9 | import { createRecord } from "../utils/data"; 10 | 11 | export function isValid(type: ValueType | undefined, value: unknown): boolean { 12 | if ( 13 | !type || 14 | ((value === undefined || value === null) && !isNonNullableType(type)) 15 | ) { 16 | return true; 17 | } 18 | 19 | return type.isOfType(value); 20 | } 21 | 22 | export function maybeGetObjectField( 23 | type: ValueType | undefined, 24 | fieldName: string 25 | ): ObjectFieldType | undefined { 26 | if (isObjectType(type)) { 27 | return type.getField(fieldName); 28 | } 29 | } 30 | 31 | export function getReferencedTypes( 32 | types: ValueType[] 33 | ): Record { 34 | const record = createRecord(); 35 | 36 | for (const type of types) { 37 | visitTypes(type, { 38 | enter: (visited) => { 39 | if (visited.name) { 40 | if (record[visited.name]) { 41 | return false; 42 | } 43 | record[visited.name] = visited; 44 | } 45 | }, 46 | }); 47 | } 48 | 49 | return record; 50 | } 51 | 52 | interface VisitConfig { 53 | enter: (type: ValueType) => boolean | void; 54 | } 55 | 56 | export function visitTypes( 57 | type: ValueType, 58 | config: VisitConfig 59 | ): boolean | undefined { 60 | if (config.enter(type) === false) { 61 | return; 62 | } 63 | 64 | if (isObjectType(type)) { 65 | for (const entry of type.getFieldEntries()) { 66 | if (entry[1].type) { 67 | visitTypes(entry[1].type, config); 68 | } 69 | } 70 | } else if (isArrayType(type)) { 71 | if (type.ofType) { 72 | visitTypes(type.ofType, config); 73 | } 74 | } else if (isUnionType(type)) { 75 | for (const unionType of type.types) { 76 | visitTypes(unionType, config); 77 | } 78 | } else if (isNonNullableType(type)) { 79 | visitTypes(type.ofType, config); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/utils/cache.ts: -------------------------------------------------------------------------------- 1 | import type { Cache } from "../Cache"; 2 | import { resolveNamedType, unwrapType, ValueType } from "../schema/types"; 3 | import type { Entity, PlainObjectWithMeta, Reference } from "../types"; 4 | import { isObject, stableValueHash } from "./data"; 5 | 6 | export function createReference(entityID: string): Reference { 7 | return { ___ref: entityID }; 8 | } 9 | 10 | export function isReference(value: unknown): value is Reference { 11 | return Boolean(isObject(value) && value.___ref); 12 | } 13 | 14 | export function isObjectWithMeta(value: unknown): value is PlainObjectWithMeta { 15 | return Boolean(isObject(value) && value.___invalidated); 16 | } 17 | 18 | export function isMetaKey(key: string): boolean { 19 | return key === "___expiresAt" || key === "___invalidated"; 20 | } 21 | 22 | function createEntityID(typeName: string, id: unknown): string { 23 | return `${typeName}:${stableValueHash(id)}`; 24 | } 25 | 26 | export function identifyByData( 27 | type: ValueType, 28 | data: unknown 29 | ): string | undefined { 30 | const id = 31 | type.name && typeof type.id === "function" ? type.id(data) : undefined; 32 | 33 | if (id !== undefined) { 34 | return createEntityID(type.name!, id); 35 | } 36 | 37 | const unwrappedType = unwrapType(type, data); 38 | 39 | if (unwrappedType) { 40 | return identifyByData(unwrappedType, data); 41 | } 42 | } 43 | 44 | export function identifyById(type: ValueType, id: unknown): string | undefined { 45 | const namedType = resolveNamedType(type); 46 | return namedType ? createEntityID(namedType.name!, id) : undefined; 47 | } 48 | 49 | export function identifyByType(type: ValueType): string | undefined { 50 | const namedType = resolveNamedType(type); 51 | return namedType ? namedType.name : undefined; 52 | } 53 | 54 | export function identify(type: ValueType, id?: unknown): string | undefined { 55 | return id === undefined ? identifyByType(type) : identifyById(type, id); 56 | } 57 | 58 | export function resolveEntity( 59 | cache: Cache, 60 | type: ValueType, 61 | id: unknown, 62 | optimistic: boolean | undefined 63 | ): Entity | undefined { 64 | const entityID = identify(type, id); 65 | 66 | if (entityID) { 67 | return cache.get(entityID, optimistic); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/utils/data.ts: -------------------------------------------------------------------------------- 1 | import { PlainObject } from "../types"; 2 | 3 | export function stableValueHash(value: unknown): string { 4 | switch (typeof value) { 5 | case "string": 6 | return value; 7 | case "number": 8 | return value.toString(); 9 | default: 10 | return JSON.stringify(value, (_, val) => 11 | isObject(val) 12 | ? Object.keys(val) 13 | .sort() 14 | .reduce((result, key) => { 15 | result[key] = val[key]; 16 | return result; 17 | }, {} as any) 18 | : val 19 | ); 20 | } 21 | } 22 | 23 | /** 24 | * This function returns `a` if `b` is deeply equal. 25 | * If not, it will replace any deeply equal children of `b` with those of `a`. 26 | * This can be used for structural sharing between JSON values for example. 27 | */ 28 | export function replaceEqualDeep(a: unknown, b: T): T; 29 | // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types 30 | export function replaceEqualDeep(a: any, b: any): any { 31 | if (a === b) { 32 | return a; 33 | } 34 | 35 | const array = Array.isArray(a) && Array.isArray(b); 36 | 37 | if (array || (isObject(a) && isObject(b))) { 38 | const aSize = array ? a.length : Object.keys(a).length; 39 | const bItems = array ? b : Object.keys(b); 40 | const bSize = bItems.length; 41 | const copy: any = array ? [] : {}; 42 | 43 | let equalItems = 0; 44 | 45 | for (let i = 0; i < bSize; i++) { 46 | const key = array ? i : bItems[i]; 47 | copy[key] = replaceEqualDeep(a[key], b[key]); 48 | if (copy[key] === a[key]) { 49 | equalItems++; 50 | } 51 | } 52 | 53 | return aSize === bSize && equalItems === aSize ? a : copy; 54 | } 55 | 56 | return b; 57 | } 58 | 59 | export function isObject(value: unknown): value is PlainObject { 60 | return Boolean(value && typeof value === "object" && !Array.isArray(value)); 61 | } 62 | 63 | export function hasOwn(obj: unknown, prop: string | number): boolean { 64 | return Object.prototype.hasOwnProperty.call(obj, prop); 65 | } 66 | 67 | export function createRecord(): Record { 68 | return Object.create(null); 69 | } 70 | 71 | export function clone(value: T): T { 72 | return JSON.parse(JSON.stringify(value)); 73 | } 74 | -------------------------------------------------------------------------------- /src/__tests__/identify.spec.ts: -------------------------------------------------------------------------------- 1 | import { Cache, schema } from ".."; 2 | 3 | describe("Identify", () => { 4 | it("should be able to identify by string id", () => { 5 | const A = schema.object({ name: "A" }); 6 | const cache = new Cache({ types: [A] }); 7 | const entityID = cache.identify({ type: "A", id: "1" }); 8 | expect(entityID).toBe("A:1"); 9 | }); 10 | 11 | it("should be able to identify by object id", () => { 12 | const A = schema.object({ name: "A" }); 13 | const cache = new Cache({ types: [A] }); 14 | const entityID = cache.identify({ type: "A", id: { page: 1 } }); 15 | expect(entityID).toBe(`A:{"page":1}`); 16 | }); 17 | 18 | it("should be able to identify objects by data", () => { 19 | const A = schema.object({ 20 | name: "A", 21 | id: (value) => value?.uid, 22 | }); 23 | const cache = new Cache({ types: [A] }); 24 | const entityID = cache.identify({ 25 | type: "A", 26 | data: { uid: "1" }, 27 | }); 28 | expect(entityID).toBe("A:1"); 29 | }); 30 | 31 | it("should be able to identify unions by data", () => { 32 | const A = schema.object({ 33 | name: "A", 34 | isOfType: (value) => value?.type === "A", 35 | }); 36 | const B = schema.object({ 37 | name: "B", 38 | isOfType: (value) => value?.type === "B", 39 | }); 40 | const Type = schema.union({ name: "Type", types: [A, B] }); 41 | const cache = new Cache({ types: [Type] }); 42 | const entityID = cache.identify({ 43 | type: "Type", 44 | data: { type: "B", id: "1" }, 45 | }); 46 | expect(entityID).toBe("B:1"); 47 | }); 48 | 49 | it("should not identify undefined values", () => { 50 | const A = schema.object({ 51 | name: "A", 52 | isOfType: (value) => value?.type === "A", 53 | }); 54 | const cache = new Cache({ types: [A] }); 55 | const entityID = cache.identify({ type: "A", data: undefined }); 56 | expect(entityID).toBe(undefined); 57 | }); 58 | 59 | it("should not identify on invalid data", () => { 60 | const A = schema.object({ 61 | name: "A", 62 | isOfType: (value) => value?.type === "A", 63 | }); 64 | const cache = new Cache({ types: [A] }); 65 | const entityID = cache.identify({ 66 | type: "A", 67 | data: { type: "A" }, 68 | }); 69 | expect(entityID).toBe(undefined); 70 | }); 71 | }); 72 | -------------------------------------------------------------------------------- /docs/CQL.md: -------------------------------------------------------------------------------- 1 | # CQL 2 | 3 | CQL stands for "Cache Query Language" and can be used to query the cache. 4 | 5 | Selectors can be used to select specific fields to a certain depth: 6 | 7 | ```js 8 | import { cql } from "normalized-cache"; 9 | 10 | const result = cache.read({ 11 | type: "Post", 12 | id: "1", 13 | select: cql`{ title comments { text } }`, 14 | }); 15 | ``` 16 | 17 | ## Star operator 18 | 19 | Use the star operator to select all fields on a certain level: 20 | 21 | ```js 22 | const result = cache.read({ 23 | type: "Post", 24 | id: "1", 25 | select: cql`{ * comments { text } }`, 26 | }); 27 | ``` 28 | 29 | ## Non-alphanumeric fields 30 | 31 | Quotes can be used to specify non-aplhanumeric fields: 32 | 33 | ```js 34 | const result = cache.read({ 35 | type: "Post", 36 | id: "1", 37 | select: cql`{ "field with spaces" { text } }`, 38 | }); 39 | ``` 40 | 41 | ## Aliasing 42 | 43 | Fields can also be aliased: 44 | 45 | ```js 46 | const result = cache.read({ 47 | type: "Post", 48 | id: "1", 49 | select: cql`{ myTitle: title } }`, 50 | }); 51 | ``` 52 | 53 | ## Inline fragments 54 | 55 | The `... on` syntax can be used to select fields on specific types: 56 | 57 | ```js 58 | const Author = schema.object({ 59 | name: "Author", 60 | fields: { 61 | name: schema.string(), 62 | }, 63 | isOfType: (value) => value?.name, 64 | }); 65 | 66 | const Post = schema.object({ 67 | name: "Post", 68 | fields: { 69 | title: schema.string(), 70 | }, 71 | isOfType: (value) => value?.title, 72 | }); 73 | 74 | const SearchResult = schema.array({ 75 | name: "SearchResult", 76 | ofType: schema.union([Author, Post]), 77 | }); 78 | 79 | const result = cache.read({ 80 | type: "SearchResult", 81 | select: cql`{ 82 | ... on Author { 83 | name 84 | } 85 | ... on Post { 86 | title 87 | } 88 | }`, 89 | }); 90 | ``` 91 | 92 | ## Fragments 93 | 94 | Fragments can be defined to name a selection of fields: 95 | 96 | ```js 97 | const result = cache.read({ 98 | type: "Post", 99 | id: "1", 100 | select: cql`fragment PostOverview on Post { title }`, 101 | }); 102 | ``` 103 | 104 | They can also be embedded in other selectors: 105 | 106 | ```js 107 | const PostOverview = cql`fragment PostOverview on Post { title }`; 108 | 109 | const result = cache.read({ 110 | type: "Post", 111 | id: "1", 112 | select: cql`{ ...PostOverview } ${PostOverview}`, 113 | }); 114 | ``` 115 | -------------------------------------------------------------------------------- /src/__tests__/delete.spec.ts: -------------------------------------------------------------------------------- 1 | import { Cache, schema } from ".."; 2 | import { cql } from "../language/tag"; 3 | 4 | describe("Delete", () => { 5 | it("should be able to delete entities", () => { 6 | const Type = schema.object({ name: "Type" }); 7 | const cache = new Cache({ types: [Type] }); 8 | cache.write({ type: "Type", data: { a: "a" } }); 9 | cache.delete({ type: "Type" }); 10 | const result = cache.read({ type: "Type" }); 11 | expect(result).toBeUndefined(); 12 | }); 13 | 14 | it("should be able to delete entity fields by selector", () => { 15 | const Type = schema.object({ name: "Type" }); 16 | const cache = new Cache({ types: [Type] }); 17 | cache.write({ type: "Type", data: { a: "a" } }); 18 | cache.delete({ type: "Type", select: cql`{ a }` }); 19 | const result = cache.read({ type: "Type", select: cql`{ a }` }); 20 | expect(result!.data).toEqual({}); 21 | }); 22 | 23 | it("should be able to delete array fields by selector", () => { 24 | const Type = schema.array({ name: "Type" }); 25 | const cache = new Cache({ types: [Type] }); 26 | cache.write({ 27 | type: "Type", 28 | data: [ 29 | { a: "a", b: "b" }, 30 | { a: "a", b: "b" }, 31 | ], 32 | }); 33 | cache.delete({ type: "Type", select: cql`{ a }` }); 34 | const result = cache.read({ type: "Type", select: cql`{ a b }` }); 35 | expect(result!.data).toEqual([{ b: "b" }, { b: "b" }]); 36 | }); 37 | 38 | it("should be able to delete nested values by selector", () => { 39 | const Child = schema.object({ name: "Child" }); 40 | const Parent = schema.object({ name: "Parent", fields: { child: Child } }); 41 | const cache = new Cache({ types: [Parent] }); 42 | cache.write({ type: "Parent", data: { child: { id: "1", a: "a" } } }); 43 | cache.delete({ type: "Parent", select: cql`{ child { a } }` }); 44 | const result = cache.read({ 45 | type: "Parent", 46 | select: cql`{ child { id a } }`, 47 | }); 48 | expect(result!.data).toEqual({ child: { id: "1" } }); 49 | }); 50 | 51 | it("should be able to delete nested references by selector", () => { 52 | const Child = schema.object({ name: "Child" }); 53 | const Parent = schema.object({ name: "Parent", fields: { child: Child } }); 54 | const cache = new Cache({ types: [Parent] }); 55 | cache.write({ type: "Parent", data: { child: { id: "1", a: "a" } } }); 56 | cache.delete({ type: "Parent", select: cql`{ child }` }); 57 | const resultParent = cache.read({ 58 | type: "Parent", 59 | select: cql`{ child { id a } }`, 60 | }); 61 | const resultChild = cache.read({ 62 | type: "Child", 63 | id: "1", 64 | }); 65 | expect(resultParent!.data).toEqual({ child: undefined }); 66 | expect(resultChild!.data).toEqual({ id: "1", a: "a" }); 67 | }); 68 | }); 69 | -------------------------------------------------------------------------------- /src/__tests__/expiration.spec.ts: -------------------------------------------------------------------------------- 1 | import { Cache, cql, schema } from ".."; 2 | import { ReadResult } from "../operations/read"; 3 | 4 | describe("Expiration", () => { 5 | it("should be able to write entities with an expiry date", () => { 6 | const Type = schema.string({ name: "Type" }); 7 | const cache = new Cache({ types: [Type] }); 8 | cache.write({ type: "Type", data: "a", expiresAt: 0 }); 9 | const result = cache.read({ type: "Type" }); 10 | const expected: Partial = { 11 | expiresAt: 0, 12 | stale: true, 13 | invalidated: false, 14 | }; 15 | expect(result).toMatchObject(expected); 16 | }); 17 | 18 | it("should be able to write specific fields with an expiry date", () => { 19 | const Type = schema.object({ name: "Type" }); 20 | const cache = new Cache({ types: [Type] }); 21 | cache.write({ type: "Type", data: { a: "a" } }); 22 | cache.write({ type: "Type", data: { b: "b" }, expiresAt: 0 }); 23 | const result = cache.read({ type: "Type" }); 24 | const expected: Partial = { 25 | expiresAt: 0, 26 | stale: true, 27 | invalidated: false, 28 | }; 29 | expect(result).toMatchObject(expected); 30 | const result2 = cache.read({ type: "Type", select: cql`{ a }` }); 31 | const expected2: Partial = { 32 | expiresAt: -1, 33 | stale: false, 34 | invalidated: false, 35 | }; 36 | expect(result2).toMatchObject(expected2); 37 | }); 38 | 39 | it("should reset entity expiry dates on write", () => { 40 | const Type = schema.string({ name: "Type" }); 41 | const cache = new Cache({ types: [Type] }); 42 | cache.write({ type: "Type", data: "a", expiresAt: 0 }); 43 | cache.write({ type: "Type", data: "a" }); 44 | const result = cache.read({ type: "Type" }); 45 | const expected: Partial = { 46 | expiresAt: -1, 47 | stale: false, 48 | invalidated: false, 49 | }; 50 | expect(result).toMatchObject(expected); 51 | }); 52 | 53 | it("should reset entity field expiry dates on write", () => { 54 | const Type = schema.object({ name: "Type" }); 55 | const cache = new Cache({ types: [Type] }); 56 | cache.write({ type: "Type", data: { a: "a" }, expiresAt: 1 }); 57 | cache.write({ type: "Type", data: { b: "b" }, expiresAt: 2 }); 58 | cache.write({ type: "Type", data: { b: "b" } }); 59 | const result = cache.read({ type: "Type", select: cql`{ b }` }); 60 | const expected: Partial = { 61 | expiresAt: -1, 62 | stale: false, 63 | invalidated: false, 64 | }; 65 | expect(result).toMatchObject(expected); 66 | const result2 = cache.read({ type: "Type", select: cql`{ a }` }); 67 | const expected2: Partial = { 68 | expiresAt: 1, 69 | stale: true, 70 | invalidated: false, 71 | }; 72 | expect(result2).toMatchObject(expected2); 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /src/operations/modify.ts: -------------------------------------------------------------------------------- 1 | import { isArrayType, resolveWrappedType, ValueType } from "../schema/types"; 2 | import type { Cache } from "../Cache"; 3 | import type { 4 | FieldNode, 5 | SelectionSetNode, 6 | DocumentNode, 7 | } from "../language/ast"; 8 | import type { EntitiesRecord, Entity, PlainObject } from "../types"; 9 | import { isReference } from "../utils/cache"; 10 | import { clone, createRecord, hasOwn, isObject } from "../utils/data"; 11 | import { getSelectionSet, getSelectionFields, updateEntities } from "./shared"; 12 | import { maybeGetObjectField } from "../schema/utils"; 13 | 14 | interface ModifyOptions { 15 | entityID: string; 16 | selector: DocumentNode | undefined; 17 | onEntity: ModifyContext["onEntity"]; 18 | onField: ModifyContext["onField"]; 19 | } 20 | 21 | export interface ModifyResult { 22 | updatedEntityIDs?: string[]; 23 | } 24 | 25 | interface ModifyContext { 26 | cache: Cache; 27 | entities: EntitiesRecord; 28 | optimistic?: boolean; 29 | selector: DocumentNode | undefined; 30 | path: (string | number)[]; 31 | onEntity: ( 32 | ctx: ModifyContext, 33 | entity: Entity, 34 | selectionSet: SelectionSetNode | undefined 35 | ) => boolean | void; 36 | onField: ( 37 | ctx: ModifyContext, 38 | parent: PlainObject, 39 | field: FieldNode 40 | ) => boolean | void; 41 | } 42 | 43 | export function executeModify( 44 | cache: Cache, 45 | type: ValueType, 46 | optimistic: boolean, 47 | options: ModifyOptions 48 | ): ModifyResult { 49 | const ctx: ModifyContext = { 50 | cache, 51 | entities: createRecord(), 52 | onEntity: options.onEntity, 53 | onField: options.onField, 54 | selector: options.selector, 55 | path: [], 56 | optimistic, 57 | }; 58 | 59 | const selectionSet = getSelectionSet(options.selector, type); 60 | 61 | traverseEntity(ctx, options.entityID, type, selectionSet); 62 | 63 | const updatedEntityIDs = updateEntities(cache, ctx.entities, optimistic); 64 | 65 | const result: ModifyResult = {}; 66 | 67 | if (updatedEntityIDs.length) { 68 | result.updatedEntityIDs = updatedEntityIDs; 69 | } 70 | 71 | return result; 72 | } 73 | 74 | function traverseEntity( 75 | ctx: ModifyContext, 76 | entityID: string, 77 | type: ValueType | undefined, 78 | selectionSet: SelectionSetNode | undefined 79 | ): void { 80 | const entity = ctx.cache.get(entityID, ctx.optimistic); 81 | 82 | if (!entity) { 83 | return; 84 | } 85 | 86 | let copy = ctx.entities[entity.id]; 87 | 88 | if (!copy) { 89 | copy = ctx.entities[entity.id] = clone(entity); 90 | } 91 | 92 | if (ctx.onEntity(ctx, copy, selectionSet) === false) { 93 | return; 94 | } 95 | 96 | traverseValue(ctx, selectionSet, type, copy.value); 97 | } 98 | 99 | function traverseValue( 100 | ctx: ModifyContext, 101 | selectionSet: SelectionSetNode | undefined, 102 | type: ValueType | undefined, 103 | data: unknown 104 | ): void { 105 | if (isReference(data)) { 106 | return traverseEntity(ctx, data.___ref, type, selectionSet); 107 | } 108 | 109 | type = type && resolveWrappedType(type, data); 110 | 111 | if (isObject(data)) { 112 | const selectionFields = getSelectionFields( 113 | ctx.selector, 114 | selectionSet, 115 | type, 116 | data 117 | ); 118 | 119 | for (const fieldName of Object.keys(selectionFields)) { 120 | if (hasOwn(data, fieldName)) { 121 | ctx.path.push(fieldName); 122 | 123 | const selectionField = selectionFields[fieldName]; 124 | 125 | if (ctx.onField(ctx, data, selectionField) !== false) { 126 | if (selectionField.selectionSet) { 127 | const objectField = maybeGetObjectField(type, fieldName); 128 | 129 | traverseValue( 130 | ctx, 131 | selectionField.selectionSet, 132 | objectField && objectField.type, 133 | data[fieldName] 134 | ); 135 | } 136 | } 137 | 138 | ctx.path.pop(); 139 | } 140 | } 141 | } else if (Array.isArray(data)) { 142 | const ofType = isArrayType(type) ? type.ofType : undefined; 143 | for (let i = 0; i < data.length; i++) { 144 | ctx.path.push(i); 145 | traverseValue(ctx, selectionSet, ofType, data[i]); 146 | ctx.path.pop(); 147 | } 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /src/operations/shared.ts: -------------------------------------------------------------------------------- 1 | import type { Cache } from "../Cache"; 2 | import { 3 | FieldNode, 4 | DocumentNode, 5 | NodeType, 6 | SelectionSetNode, 7 | FragmentDefinitionNode, 8 | } from "../language/ast"; 9 | import { isObjectType, ValueType } from "../schema/types"; 10 | import type { EntitiesRecord, PlainObject } from "../types"; 11 | import { isMetaKey } from "../utils/cache"; 12 | import { ErrorCode, invariant } from "../utils/invariant"; 13 | 14 | export function getSelectionSet( 15 | node: DocumentNode | undefined, 16 | type: ValueType | undefined 17 | ): SelectionSetNode | undefined { 18 | if (node) { 19 | const selector = node.definitions[0]; 20 | 21 | if (selector.kind === NodeType.FragmentDefinition) { 22 | invariant( 23 | !type || selector.typeCondition.name.value === type.name, 24 | process.env.NODE_ENV === "production" 25 | ? ErrorCode.SELECTOR_SCHEMA_MISMATCH 26 | : `The fragment type "${selector.typeCondition.name.value}" does not match the schema type "${type?.name}"` 27 | ); 28 | return selector.selectionSet; 29 | } 30 | 31 | return selector; 32 | } 33 | } 34 | 35 | export function getSelectionFields( 36 | document: DocumentNode | undefined, 37 | selectionSet: SelectionSetNode | undefined, 38 | type: ValueType | undefined, 39 | data: PlainObject, 40 | fields: Record = {} 41 | ): Record { 42 | if (document && selectionSet) { 43 | for (const selection of selectionSet.selections) { 44 | if (selection.kind === NodeType.InlineFragment) { 45 | if ( 46 | !selection.typeCondition || 47 | (type && type.name === selection.typeCondition.name.value) 48 | ) { 49 | getSelectionFields( 50 | document, 51 | selection.selectionSet, 52 | type, 53 | data, 54 | fields 55 | ); 56 | } 57 | } else if (selection.kind === NodeType.Star) { 58 | addAllFields(fields, type, data); 59 | } else if (selection.kind === NodeType.FragmentSpread) { 60 | const fragDefinition = document.definitions.find( 61 | (def) => 62 | def.kind === NodeType.FragmentDefinition && 63 | def.name.value === selection.name.value 64 | ) as FragmentDefinitionNode | undefined; 65 | 66 | invariant( 67 | fragDefinition, 68 | process.env.NODE_ENV === "production" 69 | ? ErrorCode.SELECTOR_SCHEMA_MISMATCH 70 | : `Fragment "${selection.name.value}" not found"` 71 | ); 72 | 73 | if (type && type.name === fragDefinition.typeCondition.name.value) { 74 | getSelectionFields( 75 | document, 76 | fragDefinition.selectionSet, 77 | type, 78 | data, 79 | fields 80 | ); 81 | } 82 | } else { 83 | fields[selection.name.value] = selection; 84 | } 85 | } 86 | } else { 87 | addAllFields(fields, type, data); 88 | } 89 | 90 | return fields; 91 | } 92 | 93 | function addAllFields( 94 | fields: Record, 95 | type: ValueType | undefined, 96 | data: PlainObject 97 | ) { 98 | for (const key of Object.keys(data)) { 99 | if (!isMetaKey(key)) { 100 | fields[key] = { 101 | kind: NodeType.Field, 102 | name: { kind: NodeType.Name, value: key }, 103 | }; 104 | } 105 | } 106 | if (isObjectType(type)) { 107 | for (const entry of type.getFieldEntries()) { 108 | fields[entry[0]] = { 109 | kind: NodeType.Field, 110 | name: { kind: NodeType.Name, value: entry[0] }, 111 | }; 112 | } 113 | } 114 | } 115 | 116 | export function updateEntities( 117 | cache: Cache, 118 | entities: EntitiesRecord, 119 | optimistic: boolean | undefined 120 | ): string[] { 121 | const updatedEntityIDs: string[] = []; 122 | 123 | cache.transaction(() => { 124 | for (const entityID of Object.keys(entities)) { 125 | const existingEntity = cache.get(entityID, optimistic); 126 | const updatedEntity = cache.set(entityID, entities[entityID], optimistic); 127 | if (updatedEntity !== existingEntity) { 128 | updatedEntityIDs.push(entityID); 129 | } 130 | } 131 | }); 132 | 133 | return updatedEntityIDs; 134 | } 135 | -------------------------------------------------------------------------------- /src/__tests__/gc.spec.ts: -------------------------------------------------------------------------------- 1 | import { Cache, schema } from ".."; 2 | 3 | describe("GC", () => { 4 | it("should remove entities which are not retained", () => { 5 | const Type = schema.string({ name: "Type" }); 6 | const cache = new Cache({ types: [Type] }); 7 | cache.write({ type: "Type", data: "a" }); 8 | cache.gc(); 9 | const result = cache.read({ type: "Type" }); 10 | expect(result).toBeUndefined(); 11 | }); 12 | 13 | it("should remove entities which are referenced in entities which are not retained", () => { 14 | const Child = schema.object({ name: "Child" }); 15 | const Parent = schema.object({ name: "Parent", fields: { child: Child } }); 16 | const cache = new Cache({ types: [Parent] }); 17 | cache.write({ type: "Parent", data: { child: { id: "1" } } }); 18 | cache.gc(); 19 | const result = cache.read({ type: "Child", id: "1" }); 20 | expect(result).toBeUndefined(); 21 | }); 22 | 23 | it("should remove unreferenced entities with circular references", () => { 24 | const Child = schema.object({ 25 | name: "Child", 26 | fields: () => ({ parent: Parent }), 27 | }); 28 | const Parent = schema.object({ name: "Parent", fields: { child: Child } }); 29 | const cache = new Cache({ types: [Parent] }); 30 | cache.write({ 31 | type: "Parent", 32 | data: { id: "1", child: { id: "2", parent: { id: "1" } } }, 33 | }); 34 | cache.gc(); 35 | const result1 = cache.read({ type: "Parent", id: "1" }); 36 | const result2 = cache.read({ type: "Child", id: "1" }); 37 | expect(result1).toBeUndefined(); 38 | expect(result2).toBeUndefined(); 39 | }); 40 | 41 | it("should not remove referenced entities with circular references", () => { 42 | const Child = schema.object({ 43 | name: "Child", 44 | fields: () => ({ parent: Parent }), 45 | }); 46 | const Parent = schema.object({ name: "Parent", fields: { child: Child } }); 47 | const cache = new Cache({ types: [Parent] }); 48 | cache.write({ 49 | type: "Parent", 50 | data: { id: "1", child: { id: "2", parent: { id: "1" } } }, 51 | }); 52 | cache.retain("Parent:1"); 53 | cache.gc(); 54 | const result1 = cache.read({ type: "Parent", id: "1" }); 55 | const result2 = cache.read({ type: "Child", id: "2" }); 56 | expect(result1).toBeDefined(); 57 | expect(result2).toBeDefined(); 58 | }); 59 | 60 | it("should remove entities which are released", () => { 61 | const Type = schema.string({ name: "Type" }); 62 | const cache = new Cache({ types: [Type] }); 63 | cache.write({ type: "Type", data: "a" }); 64 | const disposeable = cache.retain("Type"); 65 | const disposeable2 = cache.retain("Type"); 66 | cache.gc(); 67 | const result1 = cache.read({ type: "Type" }); 68 | expect(result1!.data).toBe("a"); 69 | disposeable.dispose(); 70 | disposeable.dispose(); 71 | cache.gc(); 72 | const result2 = cache.read({ type: "Type" }); 73 | expect(result2!.data).toBe("a"); 74 | disposeable2.dispose(); 75 | cache.gc(); 76 | const result3 = cache.read({ type: "Type" }); 77 | expect(result3).toBeUndefined(); 78 | }); 79 | 80 | it("should not remove entities which are retained", () => { 81 | const Type = schema.string({ name: "Type" }); 82 | const cache = new Cache({ types: [Type] }); 83 | cache.write({ type: "Type", data: "a" }); 84 | cache.retain("Type"); 85 | cache.gc(); 86 | const result = cache.read({ type: "Type" }); 87 | expect(result!.data).toBe("a"); 88 | }); 89 | 90 | it("should not remove entities which are watched", () => { 91 | const Type = schema.string({ name: "Type" }); 92 | const cache = new Cache({ types: [Type] }); 93 | cache.write({ type: "Type", data: "a" }); 94 | cache.watch({ type: "Type", callback: () => undefined }); 95 | cache.gc(); 96 | const result = cache.read({ type: "Type" }); 97 | expect(result!.data).toBe("a"); 98 | }); 99 | 100 | it("should not remove entities which are referenced in entities which are retained", () => { 101 | const Child = schema.object({ name: "Child" }); 102 | const Parent = schema.object({ name: "Parent", fields: { child: Child } }); 103 | const cache = new Cache({ types: [Parent] }); 104 | cache.write({ type: "Parent", data: { child: { id: "1" } } }); 105 | cache.retain("Parent"); 106 | cache.gc(); 107 | const result = cache.read({ type: "Child", id: "1" }); 108 | expect(result!.data).toEqual({ id: "1" }); 109 | }); 110 | }); 111 | -------------------------------------------------------------------------------- /src/__tests__/watch.spec.ts: -------------------------------------------------------------------------------- 1 | import { Cache, ReadResult, cql, schema } from ".."; 2 | 3 | describe("Watch", () => { 4 | it("should be able to watch data", () => { 5 | const Type = schema.object({ name: "Type" }); 6 | const cache = new Cache({ types: [Type] }); 7 | const callback = jest.fn(); 8 | cache.watch({ type: "Type", callback }); 9 | cache.write({ type: "Type", data: { a: "a" } }); 10 | 11 | const newResult: ReadResult = { 12 | entityID: "Type", 13 | data: { a: "a" }, 14 | invalidated: false, 15 | expiresAt: -1, 16 | stale: false, 17 | }; 18 | 19 | expect(callback).toHaveBeenCalledWith( 20 | expect.objectContaining(newResult), 21 | undefined 22 | ); 23 | }); 24 | 25 | it("should be able to unsubscribe", () => { 26 | const Type = schema.object({ name: "Type" }); 27 | const cache = new Cache({ types: [Type] }); 28 | const callback = jest.fn(); 29 | const { unsubscribe } = cache.watch({ type: "Type", callback }); 30 | cache.write({ type: "Type", data: { a: "a" } }); 31 | unsubscribe(); 32 | cache.write({ type: "Type", data: { a: "b" } }); 33 | expect(callback).toHaveBeenCalledTimes(1); 34 | }); 35 | 36 | it("should notify when the selected data changed", () => { 37 | const Type = schema.object({ name: "Type" }); 38 | const cache = new Cache({ types: [Type] }); 39 | const callback = jest.fn(); 40 | cache.write({ type: "Type", data: { a: "a", b: "b" } }); 41 | cache.watch({ type: "Type", select: cql`{ b }`, callback }); 42 | cache.write({ type: "Type", data: { b: "bb" } }); 43 | 44 | const prevResult: ReadResult = { 45 | data: { b: "b" }, 46 | entityID: "Type", 47 | invalidated: false, 48 | expiresAt: -1, 49 | selector: expect.anything(), 50 | stale: false, 51 | }; 52 | 53 | const newResult: ReadResult = { 54 | data: { b: "bb" }, 55 | entityID: "Type", 56 | invalidated: false, 57 | expiresAt: -1, 58 | selector: expect.anything(), 59 | stale: false, 60 | }; 61 | 62 | expect(callback).toHaveBeenCalledWith(newResult, prevResult); 63 | }); 64 | 65 | it("should not notify when the written data did not change", () => { 66 | const Type = schema.object({ name: "Type" }); 67 | const cache = new Cache({ types: [Type] }); 68 | const callback = jest.fn(); 69 | cache.write({ type: "Type", data: { a: "a" } }); 70 | cache.watch({ type: "Type", callback }); 71 | cache.write({ type: "Type", data: { a: "a" } }); 72 | expect(callback).not.toHaveBeenCalled(); 73 | }); 74 | 75 | it("should not notify when the selected data did not change", () => { 76 | const Type = schema.object({ name: "Type" }); 77 | const cache = new Cache({ types: [Type] }); 78 | const callback = jest.fn(); 79 | cache.write({ type: "Type", data: { a: "a", b: "b" } }); 80 | cache.watch({ type: "Type", select: cql`{ b }`, callback }); 81 | cache.write({ type: "Type", data: { a: "aa" } }); 82 | expect(callback).not.toHaveBeenCalled(); 83 | }); 84 | 85 | it("should not notify when a write is done in silent mode", () => { 86 | const Type = schema.object({ name: "Type" }); 87 | const cache = new Cache({ types: [Type] }); 88 | const callback = jest.fn(); 89 | cache.write({ type: "Type", data: { a: "a", b: "b" } }); 90 | cache.watch({ type: "Type", select: cql`{ b }`, callback }); 91 | cache.silent(() => { 92 | cache.write({ type: "Type", data: { b: "bb" } }); 93 | }); 94 | expect(callback).not.toHaveBeenCalled(); 95 | }); 96 | 97 | it("should notify once if multiple writes are done within a transaction", () => { 98 | const Type = schema.object({ name: "Type" }); 99 | const cache = new Cache({ types: [Type] }); 100 | const callback = jest.fn(); 101 | cache.write({ type: "Type", data: { a: "a", b: "b" } }); 102 | cache.watch({ type: "Type", select: cql`{ b }`, callback }); 103 | cache.transaction(() => { 104 | cache.write({ type: "Type", data: { b: "bb" } }); 105 | cache.write({ type: "Type", data: { b: "cc" } }); 106 | }); 107 | const prevResult: Partial = { 108 | data: { b: "b" }, 109 | }; 110 | const newResult: Partial = { 111 | data: { b: "cc" }, 112 | }; 113 | expect(callback).toHaveBeenCalledTimes(1); 114 | expect(callback).toHaveBeenCalledWith( 115 | expect.objectContaining(newResult), 116 | expect.objectContaining(prevResult) 117 | ); 118 | }); 119 | }); 120 | -------------------------------------------------------------------------------- /docs/Integration.md: -------------------------------------------------------------------------------- 1 | # Integration 2 | 3 | This library can be used as internal cache for fetching libraries. 4 | 5 | Other functionality like queries, mutations and computed fields can be build on top of the cache. 6 | 7 | The `cql` language and `schema` builders are optional features which both return an AST. 8 | 9 | Other packages can create their own languages and translate parts of it to an AST for querying the cache. 10 | 11 | ## GraphQL support 12 | 13 | It is possible to use the cache with GraphQL, but some additional work 14 | is needed when using field arguments or directives in your queries. 15 | 16 | When querying a field like `image(width: 100)`, the query response will only contain an `image` field. 17 | If the data is stored in the cache as `image` and another query is executed with `image(width: 200)`, 18 | the result for `image(width: 100)` will be overwritten. 19 | 20 | This means the response first needs to be transformed before storing it in the cache: 21 | 22 | ```js 23 | cache.write({ 24 | type: "TodoQuery", 25 | data: { id: "1", "image(width: 100)": "url" }, 26 | }); 27 | ``` 28 | 29 | It also means that the result needs to be transformed again when reading from the cache. 30 | 31 | Utility functions can be defined to automate the process: 32 | 33 | ```js 34 | function graphQLWrite(options) { 35 | return cache.write({ 36 | ...options, 37 | id: gqlIdentify(options), 38 | data: gqlNormalize(options), 39 | }); 40 | } 41 | 42 | function graphQLRead(options) { 43 | const result = cache.read({ 44 | ...options, 45 | id: gqlIdentify(options), 46 | }); 47 | 48 | return { ...result, data: gqlDenormalize(options) }; 49 | } 50 | 51 | const variables = { id: "1" }; 52 | const response = await fetch(TODO_QUERY, variables); 53 | 54 | graphQLWrite({ 55 | type: "TodoQuery", 56 | query: TODO_QUERY, 57 | variables, 58 | data: response, 59 | }); 60 | 61 | graphQLRead({ 62 | type: "TodoQuery", 63 | query: TODO_QUERY, 64 | variables, 65 | select: cql`{ title }`, 66 | }); 67 | ``` 68 | 69 | ## Fetch library integration 70 | 71 | - When data is fetched or updated in a query, store the data in the normalized cached 72 | with the specified schema and query hash and subscribe to the returned selector. 73 | - On normalized cache update, run Query.setData. 74 | - Unsubscribe from the selector when the last observer unsubscribes. 75 | - Subscribe again to the selector and check data when an observer subscribes again. 76 | 77 | ```js 78 | useQuery({ 79 | type: PostsQuery, 80 | queryKey: ["posts", page, limit], 81 | queryFn: async () => getPosts({ page, limit }), 82 | }); 83 | ``` 84 | 85 | ### GraphQL type? 86 | 87 | A `Post` might have already been fetched in a list query for example, but the cache 88 | does not know about the relationship between `PostQuery(id : "1")` and `Post:1`. 89 | The `link` property can be used to link a field to an existing entity in the cache. 90 | If all selected fields are already in the cache, there is no need to execute the resolver. 91 | If some fields are missing, the cache can already return a partial result and fetch the missing fields. 92 | 93 | ```js 94 | const Comment = schema.object({ 95 | name: "Comment", 96 | args: { id: schema.number({ optional: false }) }, 97 | }); 98 | 99 | // Able to get from cache but it cannot resolve on its own 100 | cql`{ Comment(id: 1) { text } }`; 101 | 102 | const Author = schema.object({ 103 | name: "Author", 104 | idFromData: (author) => author.uid, 105 | idFromArgs: (args) => args.uid, 106 | args: { uid: schema.number({ optional: false }) }, 107 | resolve: (parent, args) => fetch(`/authors/${args.uid}`), 108 | }); 109 | 110 | cql`{ Author(uid: 1) { name } }`; 111 | 112 | const Post = schema.object({ 113 | name: "Post", 114 | args: { id: schema.number({ optional: false }) }, 115 | resolve: (parent, args) => fetch(`/posts/${args.id}`), 116 | fields: { 117 | comments: [Comment], 118 | // what if the author field also has args? 119 | author: schema.computed({ 120 | type: Author, 121 | resolve: (post, args, ctx) => 122 | ctx.query("Author", { uid: post.authorUID }), 123 | }), 124 | authors: schema.computed({ 125 | type: [Author], 126 | resolve: (post, args, ctx) => 127 | post.authorUIDs.map((uid) => ctx.query("Author", { uid })), 128 | }), 129 | image: schema.string({ 130 | args: { size: schema.number() }, 131 | resolve: (post, args) => post.image.replace("{size}", args.size), 132 | }), 133 | }, 134 | }); 135 | 136 | cql`{ Post(id: 1) { title author { name } } }`; 137 | 138 | const Posts = schema.array({ 139 | name: "Posts", 140 | args: { page: schema.number() }, 141 | ofType: Post, 142 | resolve: (_, args) => fetch(`/posts?page=${args.page}`), 143 | }); 144 | 145 | cql`{ Posts(page: 1) { title } }`; 146 | 147 | const FeaturedPosts = schema.array({ 148 | name: "FeaturedPosts", 149 | args: { page: schema.number() }, 150 | ofType: Post, 151 | resolve: (_, args) => fetch(`/featured-posts?page=${args.page}`), 152 | }); 153 | 154 | cql`{ FeaturedPosts(page: 1) { title } }`; 155 | 156 | const Posts = schema.computed({ 157 | name: "Post", 158 | args: { id: schema.number() }, 159 | returns: Post, 160 | // Executes when nothing is found in the cache 161 | link(parent, args, cache) { 162 | return cache.identify("Post", { id: args.id }); 163 | }, 164 | // Executes when some selected data is missing from the cache 165 | resolve(parent, args) { 166 | return fetch(`/posts/${args.id}`); 167 | }, 168 | // Executes when being read from the cache 169 | read(parent, args, cache) {}, 170 | // Executes when being written to the cache 171 | write(parent, args, cache) {}, 172 | }); 173 | 174 | const selector = cql`{ PostQuery(id: "1") { title } }`; 175 | ``` 176 | -------------------------------------------------------------------------------- /docs/Schema.md: -------------------------------------------------------------------------------- 1 | # Schema 2 | 3 | A schema can be provided to the cache so it knows about the shape of the data. 4 | 5 | It can be used to define the entities and their relationships, but also any data types. 6 | 7 | ## Structure 8 | 9 | Types can be defined with factory functions on the `schema` object: 10 | 11 | ```js 12 | import { schema } from "normalized-cache"; 13 | 14 | const Post = schema.object({ 15 | name: "Post", 16 | }); 17 | ``` 18 | 19 | ## Registration 20 | 21 | Types can be registered when initializing the cache: 22 | 23 | ```js 24 | const cache = new Cache({ 25 | types: [Post], 26 | }); 27 | ``` 28 | 29 | All types referenced by `Post` will be automatically added. 30 | 31 | ## Entities 32 | 33 | An entity is a type which has a `name` and an `id`. Data matching these types will be stored normalized. 34 | 35 | ```js 36 | const Post = schema.object({ 37 | name: "Post", 38 | }); 39 | 40 | cache.write({ 41 | type: "Post", 42 | data: { id: "1", title: "Title" }, 43 | }); 44 | 45 | const result = cache.read({ 46 | type: "Post", 47 | id: "1", 48 | }); 49 | ``` 50 | 51 | By default the cache will look for an `id` property in the data but a custom `id` function can also be defined on the type: 52 | 53 | ```js 54 | const Post = schema.object({ 55 | name: "Post", 56 | id: (post) => post.uid, 57 | }); 58 | 59 | cache.write({ 60 | type: "Post", 61 | data: { uid: "1", title: "Title" }, 62 | }); 63 | 64 | const result = cache.read({ 65 | type: "Post", 66 | id: "1", 67 | }); 68 | ``` 69 | 70 | An ID can also be specified when writing: 71 | 72 | ```js 73 | const Post = schema.object({ 74 | name: "Post", 75 | }); 76 | 77 | cache.write({ 78 | type: "Post", 79 | id: "1", 80 | data: { title: "Title" }, 81 | }); 82 | 83 | const result = cache.read({ 84 | type: "Post", 85 | id: "1", 86 | }); 87 | ``` 88 | 89 | When no ID is provided the entity will be stored as singleton: 90 | 91 | ```js 92 | const Post = schema.object({ 93 | name: "Post", 94 | }); 95 | 96 | cache.write({ 97 | type: "Post", 98 | data: { title: "Title" }, 99 | }); 100 | 101 | const result = cache.read({ 102 | type: "Post", 103 | }); 104 | ``` 105 | 106 | ## Relationships 107 | 108 | Relationships are defined by refering to another type: 109 | 110 | ```js 111 | const Author = schema.object({ 112 | name: "Author", 113 | }); 114 | 115 | const Comment = schema.object({ 116 | name: "Comment", 117 | }); 118 | 119 | const Post = schema.object({ 120 | name: "Post", 121 | fields: { 122 | author: Author, 123 | comments: [Comment], 124 | }, 125 | }); 126 | ``` 127 | 128 | ## Types 129 | 130 | ### Strings 131 | 132 | Defining a string: 133 | 134 | ```js 135 | schema.string(); 136 | ``` 137 | 138 | Defining a string constant: 139 | 140 | ```js 141 | schema.string("DRAFT"); 142 | ``` 143 | 144 | ### Numbers 145 | 146 | Defining a number: 147 | 148 | ```js 149 | schema.number(); 150 | ``` 151 | 152 | Defining a number constant: 153 | 154 | ```js 155 | schema.number(0); 156 | ``` 157 | 158 | ### Booleans 159 | 160 | Defining a boolean: 161 | 162 | ```js 163 | schema.boolean(); 164 | ``` 165 | 166 | Defining a boolean constant: 167 | 168 | ```js 169 | schema.boolean(true); 170 | ``` 171 | 172 | ### Objects 173 | 174 | Defining an anonymous object: 175 | 176 | ```js 177 | schema.object(); 178 | ``` 179 | 180 | Defining a named object: 181 | 182 | ```js 183 | const Post = schema.object({ 184 | name: "Post", 185 | }); 186 | ``` 187 | 188 | #### Fields 189 | 190 | Defining object fields: 191 | 192 | ```js 193 | const Post = schema.object({ 194 | name: "Post", 195 | fields: { 196 | title: schema.nonNullable(schema.string()), 197 | createdAt: schema.number(), 198 | }, 199 | }); 200 | ``` 201 | 202 | #### Computed fields 203 | 204 | Computed fields can be created by defining a `read` function. 205 | 206 | They can be used for calculations or dynamically mapping fields to entities: 207 | 208 | ```js 209 | const Author = schema.object({ 210 | name: "Author", 211 | }); 212 | 213 | const Post = schema.object({ 214 | name: "Post", 215 | fields: { 216 | author: { 217 | read: (post, { toReference }) => { 218 | return toReference({ type: "Author", id: post.authorId }); 219 | }, 220 | }, 221 | }, 222 | }); 223 | ``` 224 | 225 | #### Fields with arguments 226 | 227 | Defining fields with arguments: 228 | 229 | ```js 230 | const Author = schema.object({ 231 | name: "Author", 232 | }); 233 | 234 | const Post = schema.object({ 235 | name: "Post", 236 | fields: { 237 | author: { 238 | type: Author, 239 | arguments: true, 240 | }, 241 | }, 242 | }); 243 | 244 | cache.write({ 245 | type: "Post", 246 | data: { 247 | 'author({"id":1})': { 248 | id: "1", 249 | name: "Name", 250 | }, 251 | }, 252 | }); 253 | ``` 254 | 255 | ### Arrays 256 | 257 | Defining an anonymous array: 258 | 259 | ```js 260 | schema.array(); 261 | ``` 262 | 263 | Defining an array of a specific type: 264 | 265 | ```js 266 | const Posts = schema.array(Post); 267 | ``` 268 | 269 | ### Unions 270 | 271 | Defining a union: 272 | 273 | ```js 274 | const PostOrComment = schema.union({ 275 | types: [Post, Comment], 276 | resolveType: (value) => (value?.title ? Post : Comment), 277 | }); 278 | ``` 279 | 280 | The `isOfType` property can also be used: 281 | 282 | ```js 283 | const Post = schema.object({ 284 | name: "Post", 285 | isOfType: (value) => value?.title, 286 | }); 287 | 288 | const Comment = schema.object({ 289 | name: "Comment", 290 | isOfType: (value) => value?.text, 291 | }); 292 | 293 | const SearchResults = schema.array({ 294 | name: "SearchResults", 295 | ofType: schema.union([Post, Comment]), 296 | }); 297 | ``` 298 | 299 | ### Non-nullable values 300 | 301 | Defining non-nullable values: 302 | 303 | ```js 304 | schema.nonNullable(schema.string()); 305 | ``` 306 | -------------------------------------------------------------------------------- /src/language/parser.ts: -------------------------------------------------------------------------------- 1 | import { ErrorCode, invariant } from "../utils/invariant"; 2 | import { 3 | DocumentNode, 4 | FieldNode, 5 | FragmentDefinitionNode, 6 | FragmentSpreadNode, 7 | InlineFragmentNode, 8 | NameNode, 9 | NodeType, 10 | SelectionSetNode, 11 | StarNode, 12 | } from "./ast"; 13 | 14 | type Parser = (state: ParserState) => ParserState; 15 | 16 | interface ParserState { 17 | src: string; 18 | pos: number; 19 | result?: any; 20 | error?: boolean; 21 | } 22 | 23 | // GENERIC COMBINATORS 24 | 25 | const recursive = (fn: () => Parser): Parser => (state) => fn()(state); 26 | 27 | const optional = (parser: Parser): Parser => (state) => { 28 | if (state.error) { 29 | return state; 30 | } 31 | const next = parser(state); 32 | return next.error ? { ...state, result: undefined } : next; 33 | }; 34 | 35 | const map = (parser: Parser, fn: (result: any) => any): Parser => (state) => { 36 | const next = parser(state); 37 | return next.error ? next : { ...next, result: fn(next.result) }; 38 | }; 39 | 40 | const sequence = (parsers: Parser[]): Parser => (state) => { 41 | if (state.error) { 42 | return state; 43 | } 44 | 45 | const results: any[] = []; 46 | let next = state; 47 | 48 | for (let i = 0; i < parsers.length; i++) { 49 | next = parsers[i](next); 50 | 51 | if (next.error) { 52 | return next; 53 | } 54 | 55 | results.push(next.result); 56 | } 57 | 58 | return { ...next, result: results }; 59 | }; 60 | 61 | const many = (parser: Parser): Parser => (state) => { 62 | if (state.error) { 63 | return state; 64 | } 65 | 66 | const results = []; 67 | let next = state; 68 | 69 | // eslint-disable-next-line no-constant-condition 70 | while (true) { 71 | const out = parser(next); 72 | if (out.error) { 73 | break; 74 | } else { 75 | next = out; 76 | results.push(next.result); 77 | if (next.pos >= next.src.length) { 78 | break; 79 | } 80 | } 81 | } 82 | 83 | return { ...next, result: results }; 84 | }; 85 | 86 | const choice = (parsers: Parser[]): Parser => (state) => { 87 | if (state.error) { 88 | return state; 89 | } 90 | 91 | for (let i = 0; i < parsers.length; i++) { 92 | const nextState = parsers[i](state); 93 | 94 | if (!nextState.error) { 95 | return nextState; 96 | } 97 | } 98 | 99 | return { ...state, error: true }; 100 | }; 101 | 102 | const regex = (re: RegExp): Parser => (state) => { 103 | if (state.error) { 104 | return state; 105 | } 106 | 107 | const match = re.exec(state.src.slice(state.pos)); 108 | 109 | if (match === null) { 110 | return { ...state, error: true }; 111 | } 112 | 113 | return { ...state, pos: state.pos + match[0].length, result: match[0] }; 114 | }; 115 | 116 | const str = (value: string): Parser => (state) => { 117 | if (state.error) { 118 | return state; 119 | } 120 | 121 | if (state.src.slice(state.pos).startsWith(value)) { 122 | return { ...state, pos: state.pos + value.length, result: value }; 123 | } 124 | 125 | return { ...state, error: true }; 126 | }; 127 | 128 | const whitespace = regex(/^\s+/); 129 | 130 | const optionalWhitespace = optional(whitespace); 131 | 132 | // SPECIFIC COMBINATORS 133 | 134 | const name = map( 135 | regex(/^[a-zA-Z0-9_]+/), 136 | (result): NameNode => ({ kind: NodeType.Name, value: result }) 137 | ); 138 | 139 | const quotedName = map( 140 | regex(/^"((?:\\.|.)*?)"/), 141 | (result): NameNode => ({ 142 | kind: NodeType.Name, 143 | value: result.substr(1, result.length - 2), 144 | }) 145 | ); 146 | 147 | const fieldName = choice([name, quotedName]); 148 | 149 | const star = map(str("*"), (): StarNode => ({ kind: NodeType.Star })); 150 | 151 | const selectionSet = recursive(() => 152 | map( 153 | sequence([str("{"), many(selection), optionalWhitespace, str("}")]), 154 | (result): SelectionSetNode => ({ 155 | kind: NodeType.SelectionSet, 156 | selections: result[1], 157 | }) 158 | ) 159 | ); 160 | 161 | const alias = map(sequence([fieldName, str(":")]), (result) => result[0]); 162 | 163 | const field = map( 164 | sequence([ 165 | optional(alias), 166 | optionalWhitespace, 167 | fieldName, 168 | optionalWhitespace, 169 | optional(selectionSet), 170 | ]), 171 | (result): FieldNode => ({ 172 | kind: NodeType.Field, 173 | alias: result[0], 174 | name: result[2], 175 | selectionSet: result[4], 176 | }) 177 | ); 178 | 179 | const fragmentSpread = map( 180 | sequence([str("..."), name]), 181 | (result): FragmentSpreadNode => ({ 182 | kind: NodeType.FragmentSpread, 183 | name: result[1], 184 | }) 185 | ); 186 | 187 | const inlineFragment = map( 188 | sequence([ 189 | str("... on"), 190 | optionalWhitespace, 191 | name, 192 | optionalWhitespace, 193 | selectionSet, 194 | ]), 195 | (result): InlineFragmentNode => ({ 196 | kind: NodeType.InlineFragment, 197 | typeCondition: { 198 | kind: NodeType.NamedType, 199 | name: result[2], 200 | }, 201 | selectionSet: result[4], 202 | }) 203 | ); 204 | 205 | const fragment = map( 206 | sequence([ 207 | str("fragment"), 208 | optionalWhitespace, 209 | name, 210 | optionalWhitespace, 211 | str("on"), 212 | optionalWhitespace, 213 | name, 214 | optionalWhitespace, 215 | selectionSet, 216 | ]), 217 | (result): FragmentDefinitionNode => ({ 218 | kind: NodeType.FragmentDefinition, 219 | name: result[2], 220 | typeCondition: { 221 | kind: NodeType.NamedType, 222 | name: result[6], 223 | }, 224 | selectionSet: result[8], 225 | }) 226 | ); 227 | 228 | const selection = map( 229 | sequence([ 230 | optionalWhitespace, 231 | choice([star, field, inlineFragment, fragmentSpread]), 232 | ]), 233 | (result) => result[1] 234 | ); 235 | 236 | const definition = map( 237 | sequence([optionalWhitespace, choice([selectionSet, fragment])]), 238 | (result) => result[1] 239 | ); 240 | 241 | const document = map( 242 | many(definition), 243 | (result): DocumentNode => ({ 244 | kind: NodeType.Document, 245 | definitions: result, 246 | }) 247 | ); 248 | 249 | export const parse = (src: string): DocumentNode => { 250 | const state = document({ pos: 0, src }); 251 | 252 | invariant( 253 | !state.error, 254 | process.env.NODE_ENV === "production" 255 | ? ErrorCode.INVALID_SELECTOR 256 | : `Unable to parse the selector ${src}` 257 | ); 258 | 259 | const documentNode = state.result; 260 | documentNode.src = src; 261 | return documentNode; 262 | }; 263 | -------------------------------------------------------------------------------- /src/language/parser.spec.ts: -------------------------------------------------------------------------------- 1 | import { DocumentNode } from "./ast"; 2 | import { parse } from "./parser"; 3 | 4 | describe("language.parse", () => { 5 | it("should be able to parse no fields", () => { 6 | const src = "{}"; 7 | const ast = parse(src); 8 | 9 | const result: DocumentNode = { 10 | kind: "Document", 11 | src, 12 | definitions: [ 13 | { 14 | kind: "SelectionSet", 15 | selections: [], 16 | }, 17 | ], 18 | }; 19 | 20 | expect(ast).toEqual(result); 21 | }); 22 | 23 | it("should be able to parse quoted field", () => { 24 | const src = '{ "fiel d" field }'; 25 | const ast = parse(src); 26 | 27 | const result: DocumentNode = { 28 | kind: "Document", 29 | src, 30 | definitions: [ 31 | { 32 | kind: "SelectionSet", 33 | selections: [ 34 | { 35 | kind: "Field", 36 | name: { kind: "Name", value: "fiel d" }, 37 | }, 38 | { 39 | kind: "Field", 40 | name: { kind: "Name", value: "field" }, 41 | }, 42 | ], 43 | }, 44 | ], 45 | }; 46 | 47 | expect(ast).toEqual(result); 48 | }); 49 | 50 | it("should be able to parse one field", () => { 51 | const src = "{ field }"; 52 | const ast = parse(src); 53 | 54 | const result: DocumentNode = { 55 | kind: "Document", 56 | src, 57 | definitions: [ 58 | { 59 | kind: "SelectionSet", 60 | selections: [ 61 | { 62 | kind: "Field", 63 | name: { kind: "Name", value: "field" }, 64 | }, 65 | ], 66 | }, 67 | ], 68 | }; 69 | 70 | expect(ast).toEqual(result); 71 | }); 72 | 73 | it("should be able to parse stars", () => { 74 | const src = "{ * }"; 75 | const ast = parse(src); 76 | 77 | const result: DocumentNode = { 78 | kind: "Document", 79 | src, 80 | definitions: [ 81 | { 82 | kind: "SelectionSet", 83 | selections: [ 84 | { 85 | kind: "Star", 86 | }, 87 | ], 88 | }, 89 | ], 90 | }; 91 | 92 | expect(ast).toEqual(result); 93 | }); 94 | 95 | it("should be able to parse multiple fields", () => { 96 | const src = "{ field1 field2 }"; 97 | const ast = parse(src); 98 | 99 | const result: DocumentNode = { 100 | kind: "Document", 101 | src, 102 | definitions: [ 103 | { 104 | kind: "SelectionSet", 105 | selections: [ 106 | { 107 | kind: "Field", 108 | name: { kind: "Name", value: "field1" }, 109 | }, 110 | { 111 | kind: "Field", 112 | name: { kind: "Name", value: "field2" }, 113 | }, 114 | ], 115 | }, 116 | ], 117 | }; 118 | 119 | expect(ast).toEqual(result); 120 | }); 121 | 122 | it("should be able to parse nested fields", () => { 123 | const src = " {field1 { nested1a nested1b }field2 {nested2a nested2b }}"; 124 | const ast = parse(src); 125 | 126 | const result: DocumentNode = { 127 | kind: "Document", 128 | src, 129 | definitions: [ 130 | { 131 | kind: "SelectionSet", 132 | selections: [ 133 | { 134 | kind: "Field", 135 | name: { kind: "Name", value: "field1" }, 136 | selectionSet: { 137 | kind: "SelectionSet", 138 | selections: [ 139 | { kind: "Field", name: { kind: "Name", value: "nested1a" } }, 140 | { kind: "Field", name: { kind: "Name", value: "nested1b" } }, 141 | ], 142 | }, 143 | }, 144 | { 145 | kind: "Field", 146 | name: { kind: "Name", value: "field2" }, 147 | selectionSet: { 148 | kind: "SelectionSet", 149 | selections: [ 150 | { kind: "Field", name: { kind: "Name", value: "nested2a" } }, 151 | { kind: "Field", name: { kind: "Name", value: "nested2b" } }, 152 | ], 153 | }, 154 | }, 155 | ], 156 | }, 157 | ], 158 | }; 159 | 160 | expect(ast).toEqual(result); 161 | }); 162 | 163 | it("should be able to parse field aliases", () => { 164 | const src = "{ alias1: field1 alias2: field2 field3 }"; 165 | const ast = parse(src); 166 | 167 | const result: DocumentNode = { 168 | kind: "Document", 169 | src, 170 | definitions: [ 171 | { 172 | kind: "SelectionSet", 173 | selections: [ 174 | { 175 | kind: "Field", 176 | alias: { kind: "Name", value: "alias1" }, 177 | name: { kind: "Name", value: "field1" }, 178 | }, 179 | { 180 | kind: "Field", 181 | alias: { kind: "Name", value: "alias2" }, 182 | name: { kind: "Name", value: "field2" }, 183 | }, 184 | { 185 | kind: "Field", 186 | name: { kind: "Name", value: "field3" }, 187 | }, 188 | ], 189 | }, 190 | ], 191 | }; 192 | 193 | expect(ast).toEqual(result); 194 | }); 195 | 196 | it("should be able to parse inline fragments", () => { 197 | const src = ` 198 | { 199 | ... on Post { 200 | title 201 | } 202 | ... on Comment { 203 | text 204 | } 205 | } 206 | `; 207 | 208 | const ast = parse(src); 209 | 210 | const result: DocumentNode = { 211 | kind: "Document", 212 | src, 213 | definitions: [ 214 | { 215 | kind: "SelectionSet", 216 | selections: [ 217 | { 218 | kind: "InlineFragment", 219 | typeCondition: { 220 | kind: "NamedType", 221 | name: { kind: "Name", value: "Post" }, 222 | }, 223 | selectionSet: { 224 | kind: "SelectionSet", 225 | selections: [ 226 | { kind: "Field", name: { kind: "Name", value: "title" } }, 227 | ], 228 | }, 229 | }, 230 | { 231 | kind: "InlineFragment", 232 | typeCondition: { 233 | kind: "NamedType", 234 | name: { kind: "Name", value: "Comment" }, 235 | }, 236 | selectionSet: { 237 | kind: "SelectionSet", 238 | selections: [ 239 | { kind: "Field", name: { kind: "Name", value: "text" } }, 240 | ], 241 | }, 242 | }, 243 | ], 244 | }, 245 | ], 246 | }; 247 | 248 | expect(ast).toEqual(result); 249 | }); 250 | }); 251 | -------------------------------------------------------------------------------- /src/__tests__/invalidation.spec.ts: -------------------------------------------------------------------------------- 1 | import { Cache, schema } from ".."; 2 | import { cql } from "../language/tag"; 3 | import { InvalidateResult } from "../operations/invalidate"; 4 | 5 | describe("Invalidation", () => { 6 | it("should invalidate an entity when no selector is given", () => { 7 | const Type = schema.object({ name: "Type" }); 8 | const cache = new Cache({ types: [Type] }); 9 | cache.write({ type: "Type", data: { a: "a", b: "b" } }); 10 | const invalidateResult = cache.invalidate({ type: "Type" }); 11 | expect(invalidateResult).toEqual({ updatedEntityIDs: ["Type"] }); 12 | const result1 = cache.read({ type: "Type" }); 13 | expect(result1!.stale).toBe(true); 14 | expect(result1!.invalidated).toBe(true); 15 | const result2 = cache.read({ type: "Type", select: cql`{ a }` }); 16 | expect(result2!.invalidated).toBe(true); 17 | const result3 = cache.read({ type: "Type", select: cql`{ b }` }); 18 | expect(result3!.invalidated).toBe(true); 19 | }); 20 | 21 | it("should remove invalidation when writing to an entity", () => { 22 | const Type = schema.string({ name: "Type" }); 23 | const cache = new Cache({ types: [Type] }); 24 | cache.write({ type: "Type", data: "a" }); 25 | cache.invalidate({ type: "Type" }); 26 | cache.write({ type: "Type", data: "a" }); 27 | const readResult = cache.read({ type: "Type" }); 28 | expect(readResult!.stale).toBe(false); 29 | expect(readResult!.invalidated).toBe(false); 30 | }); 31 | 32 | it("should be able to invalidate all entity fields", () => { 33 | const Type = schema.object({ name: "Type" }); 34 | const cache = new Cache({ types: [Type] }); 35 | cache.write({ type: "Type", data: { a: "a", b: "b" } }); 36 | const invalidateResult = cache.invalidate({ 37 | type: "Type", 38 | select: cql`{ * }`, 39 | }); 40 | expect(invalidateResult).toEqual({ updatedEntityIDs: ["Type"] }); 41 | const result1 = cache.read({ type: "Type" }); 42 | expect(result1!.invalidated).toBe(true); 43 | const result2 = cache.read({ type: "Type", select: cql`{ a }` }); 44 | expect(result2!.invalidated).toBe(true); 45 | const result3 = cache.read({ type: "Type", select: cql`{ b }` }); 46 | expect(result3!.invalidated).toBe(true); 47 | }); 48 | 49 | it("should be able to invalidate an entity field", () => { 50 | const Type = schema.object({ name: "Type" }); 51 | const cache = new Cache({ types: [Type] }); 52 | cache.write({ type: "Type", data: { a: "a", b: "b" } }); 53 | const invalidateResult = cache.invalidate({ 54 | type: "Type", 55 | select: cql`{ a }`, 56 | }); 57 | expect(invalidateResult).toEqual({ updatedEntityIDs: ["Type"] }); 58 | const result1 = cache.read({ type: "Type" }); 59 | expect(result1!.invalidated).toBe(true); 60 | const result2 = cache.read({ type: "Type", select: cql`{ a }` }); 61 | expect(result2!.invalidated).toBe(true); 62 | const result3 = cache.read({ type: "Type", select: cql`{ b }` }); 63 | expect(result3!.invalidated).toBe(false); 64 | }); 65 | 66 | it("should not invalidate nested entities when all fields are selected", () => { 67 | const Child = schema.object({ name: "Child" }); 68 | const Type = schema.object({ name: "Type", fields: { child: Child } }); 69 | const cache = new Cache({ types: [Type] }); 70 | cache.write({ type: "Type", data: { child: { id: "1", a: { a: "a" } } } }); 71 | const result = cache.invalidate({ 72 | type: "Type", 73 | select: cql`{ * }`, 74 | }); 75 | const expected: InvalidateResult = { 76 | updatedEntityIDs: ["Type"], 77 | }; 78 | expect(result).toEqual(expected); 79 | const result2 = cache.read({ 80 | type: "Type", 81 | select: cql`{ child }`, 82 | }); 83 | expect(result2!.invalidated).toBe(true); 84 | const result3 = cache.read({ 85 | type: "Child", 86 | id: "1", 87 | }); 88 | expect(result3!.invalidated).toBe(false); 89 | }); 90 | 91 | it("should be able to invalidate a nested entity field", () => { 92 | const Child = schema.object({ name: "Child" }); 93 | const Type = schema.object({ name: "Type", fields: { child: Child } }); 94 | const cache = new Cache({ types: [Type] }); 95 | cache.write({ type: "Type", data: { child: { id: "1", a: { a: "a" } } } }); 96 | const result = cache.invalidate({ 97 | type: "Type", 98 | select: cql`{ child { a } }`, 99 | }); 100 | const expected: InvalidateResult = { 101 | updatedEntityIDs: ["Child:1"], 102 | }; 103 | expect(result).toEqual(expected); 104 | const result2 = cache.read({ 105 | type: "Type", 106 | select: cql`{ child }`, 107 | }); 108 | expect(result2!.invalidated).toBe(true); 109 | const result3 = cache.read({ 110 | type: "Child", 111 | id: "1", 112 | select: cql`{ id }`, 113 | }); 114 | expect(result3!.invalidated).toBe(false); 115 | const result4 = cache.read({ 116 | type: "Child", 117 | id: "1", 118 | select: cql`{ a }`, 119 | }); 120 | expect(result4!.invalidated).toBe(true); 121 | }); 122 | 123 | it("should be able to invalidate nested fields unknown to the schema", () => { 124 | const Type = schema.object({ name: "Type" }); 125 | const cache = new Cache({ types: [Type] }); 126 | cache.write({ type: "Type", data: { a: { b: "b" } } }); 127 | const result = cache.invalidate({ 128 | type: "Type", 129 | select: cql`{ a { b } }`, 130 | }); 131 | const expected: InvalidateResult = { 132 | updatedEntityIDs: ["Type"], 133 | }; 134 | expect(result).toEqual(expected); 135 | const result2 = cache.read({ 136 | type: "Type", 137 | select: cql`{ a }`, 138 | }); 139 | expect(result2!.invalidated).toBe(true); 140 | }); 141 | 142 | it("should be able to invalidate object fields within arrays", () => { 143 | const Type = schema.array({ name: "Type", ofType: schema.object() }); 144 | const cache = new Cache({ types: [Type] }); 145 | cache.write({ 146 | type: "Type", 147 | data: [ 148 | { c: "c", d: "d" }, 149 | { a: "a", b: "b" }, 150 | ], 151 | }); 152 | const invalidateResult = cache.invalidate({ 153 | type: "Type", 154 | select: cql`{ a }`, 155 | }); 156 | expect(invalidateResult).toEqual({ updatedEntityIDs: ["Type"] }); 157 | const result1 = cache.read({ type: "Type" }); 158 | expect(result1!.invalidated).toBe(true); 159 | const result2 = cache.read({ type: "Type", select: cql`{ a }` }); 160 | expect(result2!.invalidated).toBe(true); 161 | const result3 = cache.read({ type: "Type", select: cql`{ b }` }); 162 | expect(result3!.invalidated).toBe(false); 163 | }); 164 | 165 | it("should remove invalidating state when deleting and inserting", () => { 166 | const Type = schema.object({ name: "Type" }); 167 | const cache = new Cache({ types: [Type] }); 168 | cache.write({ type: "Type", data: { a: "a", b: "b" } }); 169 | cache.delete({ type: "Type", select: cql`{ a }` }); 170 | cache.invalidate({ type: "Type", select: cql`{ a }` }); 171 | const result1 = cache.read({ type: "Type", select: cql`{ a }` }); 172 | expect(result1!.invalidated).toBe(false); 173 | cache.write({ type: "Type", data: { a: "a" } }); 174 | const result2 = cache.read({ type: "Type", select: cql`{ a }` }); 175 | expect(result2!.invalidated).toBe(false); 176 | }); 177 | }); 178 | -------------------------------------------------------------------------------- /src/operations/read.ts: -------------------------------------------------------------------------------- 1 | import type { Cache } from "../Cache"; 2 | import type { Entity, InvalidField, MissingField, PlainObject } from "../types"; 3 | import { 4 | isArrayType, 5 | isObjectType, 6 | ObjectFieldReadContext, 7 | resolveWrappedType, 8 | ValueType, 9 | } from "../schema/types"; 10 | import { SelectionSetNode, DocumentNode } from "../language/ast"; 11 | import { 12 | createReference, 13 | isObjectWithMeta, 14 | isReference, 15 | resolveEntity, 16 | } from "../utils/cache"; 17 | import { hasOwn } from "../utils/data"; 18 | import { getSelectionSet, getSelectionFields } from "./shared"; 19 | import { isValid, maybeGetObjectField } from "../schema/utils"; 20 | 21 | interface ReadOptions { 22 | id?: unknown; 23 | onlyReadKnownFields?: boolean; 24 | select?: DocumentNode; 25 | } 26 | 27 | export interface ReadResult { 28 | data: T; 29 | entityID: string; 30 | expiresAt: number; 31 | invalidFields?: InvalidField[]; 32 | invalidated: boolean; 33 | missingFields?: MissingField[]; 34 | selector?: DocumentNode; 35 | stale: boolean; 36 | } 37 | 38 | interface ReadContext { 39 | cache: Cache; 40 | expiresAt: number; 41 | invalidFields: InvalidField[]; 42 | invalidated: boolean; 43 | missingFields: MissingField[]; 44 | /** 45 | * Keeps track of the results of entities which were selected without selection set. 46 | * This is used to build results with circular references. 47 | */ 48 | fullEntityResults: Record; 49 | onlyReadKnownFields?: boolean; 50 | optimistic?: boolean; 51 | path: (string | number)[]; 52 | selector: DocumentNode | undefined; 53 | } 54 | 55 | export function executeRead( 56 | cache: Cache, 57 | type: ValueType, 58 | optimistic: boolean, 59 | options: ReadOptions 60 | ): ReadResult | undefined { 61 | const entity = resolveEntity(cache, type, options.id, optimistic); 62 | 63 | if (!entity) { 64 | return; 65 | } 66 | 67 | const ctx: ReadContext = { 68 | cache, 69 | expiresAt: -1, 70 | invalidFields: [], 71 | invalidated: false, 72 | missingFields: [], 73 | fullEntityResults: {}, 74 | onlyReadKnownFields: options.onlyReadKnownFields, 75 | optimistic, 76 | selector: options.select, 77 | path: [], 78 | }; 79 | 80 | const selectionSet = getSelectionSet(options.select, type); 81 | 82 | const data = traverseEntity(ctx, entity.id, type, selectionSet); 83 | 84 | const result: ReadResult = { 85 | data, 86 | entityID: entity.id, 87 | expiresAt: ctx.expiresAt, 88 | invalidated: ctx.invalidated, 89 | selector: options.select, 90 | stale: 91 | ctx.invalidated || (ctx.expiresAt !== -1 && ctx.expiresAt <= Date.now()), 92 | }; 93 | 94 | if (ctx.missingFields.length) { 95 | result.missingFields = ctx.missingFields; 96 | } 97 | 98 | if (ctx.invalidFields.length) { 99 | result.invalidFields = ctx.invalidFields; 100 | } 101 | 102 | return result; 103 | } 104 | 105 | function traverseEntity( 106 | ctx: ReadContext, 107 | entityID: string, 108 | type: ValueType | undefined, 109 | selectionSet: SelectionSetNode | undefined 110 | ): any { 111 | const entity = ctx.cache.get(entityID, ctx.optimistic); 112 | 113 | if (!entity) { 114 | addMissingField(ctx); 115 | return; 116 | } 117 | 118 | if (!selectionSet && ctx.fullEntityResults[entity.id]) { 119 | return ctx.fullEntityResults[entity.id]; 120 | } 121 | 122 | checkExpiresAt(ctx, entity.expiresAt); 123 | checkInvalidated(ctx, entity.invalidated); 124 | 125 | return traverseValue(ctx, selectionSet, type, entity, entity.value); 126 | } 127 | 128 | function traverseValue( 129 | ctx: ReadContext, 130 | selectionSet: SelectionSetNode | undefined, 131 | type: ValueType | undefined, 132 | entity: Entity | undefined, 133 | data: unknown 134 | ): any { 135 | if (isReference(data)) { 136 | return traverseEntity(ctx, data.___ref, type, selectionSet); 137 | } 138 | 139 | if (type) { 140 | if (isValid(type, data)) { 141 | type = resolveWrappedType(type, data); 142 | } else { 143 | addInvalidField(ctx, data); 144 | } 145 | } 146 | 147 | if (isObjectWithMeta(data)) { 148 | const result: PlainObject = {}; 149 | 150 | if (entity && !selectionSet) { 151 | ctx.fullEntityResults[entity.id] = result; 152 | } 153 | 154 | const selectionFields = getSelectionFields( 155 | ctx.selector, 156 | selectionSet, 157 | type, 158 | data 159 | ); 160 | 161 | for (const fieldName of Object.keys(selectionFields)) { 162 | ctx.path.push(fieldName); 163 | 164 | if (shouldReadField(ctx, type, fieldName)) { 165 | const objectField = maybeGetObjectField(type, fieldName); 166 | 167 | const selectionField = selectionFields[fieldName]; 168 | 169 | let fieldValue: unknown; 170 | let fieldValueFound = false; 171 | 172 | if (objectField && objectField.read) { 173 | const fieldReadCtx = createObjectFieldReadContext(ctx.cache); 174 | fieldValue = objectField.read(data, fieldReadCtx); 175 | fieldValueFound = true; 176 | } else if (hasOwn(data, fieldName)) { 177 | fieldValue = data[fieldName]; 178 | fieldValueFound = true; 179 | } 180 | 181 | if (fieldValueFound) { 182 | checkExpiresAt(ctx, data.___expiresAt[fieldName]); 183 | checkInvalidated(ctx, data.___invalidated[fieldName]); 184 | 185 | const alias = selectionField.alias 186 | ? selectionField.alias.value 187 | : fieldName; 188 | 189 | result[alias] = traverseValue( 190 | ctx, 191 | selectionField.selectionSet, 192 | objectField && objectField.type, 193 | undefined, 194 | fieldValue 195 | ); 196 | } else { 197 | addMissingField(ctx); 198 | } 199 | } 200 | 201 | ctx.path.pop(); 202 | } 203 | 204 | return result; 205 | } 206 | 207 | if (Array.isArray(data)) { 208 | const ofType = isArrayType(type) ? type.ofType : undefined; 209 | const result: unknown[] = []; 210 | 211 | for (let i = 0; i < data.length; i++) { 212 | ctx.path.push(i); 213 | result.push(traverseValue(ctx, selectionSet, ofType, undefined, data[i])); 214 | ctx.path.pop(); 215 | } 216 | 217 | return result; 218 | } 219 | 220 | return data; 221 | } 222 | 223 | function addMissingField(ctx: ReadContext) { 224 | ctx.missingFields.push({ path: [...ctx.path] }); 225 | } 226 | 227 | function addInvalidField(ctx: ReadContext, value: unknown) { 228 | ctx.invalidFields.push({ path: [...ctx.path], value }); 229 | } 230 | 231 | function checkInvalidated(ctx: ReadContext, invalidated: boolean) { 232 | if (invalidated) { 233 | ctx.invalidated = true; 234 | } 235 | } 236 | 237 | function checkExpiresAt(ctx: ReadContext, expiresAt: number) { 238 | if (expiresAt !== -1 && (ctx.expiresAt === -1 || expiresAt < ctx.expiresAt)) { 239 | ctx.expiresAt = expiresAt; 240 | } 241 | } 242 | 243 | function createObjectFieldReadContext(cache: Cache): ObjectFieldReadContext { 244 | return { 245 | toReference: (options) => { 246 | const entityID = cache.identify(options); 247 | if (entityID) { 248 | return createReference(entityID); 249 | } 250 | }, 251 | }; 252 | } 253 | 254 | function shouldReadField( 255 | ctx: ReadContext, 256 | type: ValueType | undefined, 257 | name: string 258 | ): boolean { 259 | if (!ctx.onlyReadKnownFields || !isObjectType(type)) { 260 | return true; 261 | } 262 | 263 | return Boolean(type.getField(name) || !type.getFieldEntries().length); 264 | } 265 | -------------------------------------------------------------------------------- /src/schema/types.ts: -------------------------------------------------------------------------------- 1 | import { Reference } from "../types"; 2 | import { isObject } from "../utils/data"; 3 | 4 | type MaybeThunk = T | (() => T); 5 | 6 | type IdFunction = (value: any) => unknown; 7 | 8 | export type WriteFunction = ( 9 | incoming: TIncoming, 10 | existing: TExisting | undefined 11 | ) => TExisting; 12 | 13 | export interface NonNullableTypeConfig { 14 | name?: string; 15 | ofType: ValueType; 16 | id?: IdFunction; 17 | } 18 | 19 | export class NonNullableType { 20 | name?: string; 21 | ofType: ValueType; 22 | id?: IdFunction; 23 | 24 | constructor(config: NonNullableTypeConfig | ValueType) { 25 | if (isValueType(config)) { 26 | this.ofType = config; 27 | } else { 28 | this.name = config.name; 29 | this.ofType = config.ofType; 30 | this.id = config.id; 31 | } 32 | } 33 | 34 | isOfType(value: unknown): boolean { 35 | return value !== undefined && value !== null && this.ofType.isOfType(value); 36 | } 37 | } 38 | 39 | export interface ArrayTypeConfig { 40 | name?: string; 41 | ofType?: ValueType; 42 | write?: WriteFunction; 43 | id?: IdFunction; 44 | } 45 | 46 | export class ArrayType { 47 | name?: string; 48 | ofType?: ValueType; 49 | write?: WriteFunction; 50 | id?: IdFunction; 51 | 52 | constructor(config?: ArrayTypeConfig | ValueType) { 53 | if (isValueType(config)) { 54 | this.ofType = config; 55 | } else if (config) { 56 | this.name = config.name; 57 | this.ofType = config.ofType; 58 | this.write = config.write; 59 | this.id = config.id; 60 | } 61 | } 62 | 63 | isOfType(value: unknown): boolean { 64 | return Array.isArray(value); 65 | } 66 | } 67 | 68 | export interface ObjectTypeConfig { 69 | name?: string; 70 | fields?: MaybeThunk< 71 | Record 72 | >; 73 | id?: IdFunction; 74 | isOfType?: (value: any) => boolean; 75 | write?: WriteFunction; 76 | } 77 | 78 | interface ToReferenceOptions { 79 | type: string; 80 | id?: unknown; 81 | data?: unknown; 82 | } 83 | 84 | export interface ObjectFieldReadContext { 85 | toReference: (options: ToReferenceOptions) => Reference | undefined; 86 | } 87 | 88 | export interface ObjectFieldWriteContext extends ObjectFieldReadContext {} 89 | 90 | export interface ObjectFieldType { 91 | type?: ValueType; 92 | arguments?: boolean; 93 | read?: (parent: any, ctx: ObjectFieldReadContext) => unknown; 94 | write?: WriteFunction; 95 | } 96 | 97 | export class ObjectType { 98 | name?: string; 99 | id?: IdFunction; 100 | write?: WriteFunction; 101 | 102 | _fields?: MaybeThunk< 103 | Record 104 | >; 105 | _resolvedFields?: Record; 106 | _resolvedFieldEntries?: [string, ObjectFieldType][]; 107 | _isOfType?: (value: any) => boolean; 108 | 109 | constructor(config: ObjectTypeConfig = {}) { 110 | this.name = config.name; 111 | this.write = config.write; 112 | this._fields = config.fields; 113 | this._isOfType = config.isOfType; 114 | if (config.id) { 115 | this.id = config.id; 116 | } else { 117 | this.id = (value) => (isObject(value) ? value.id : undefined); 118 | } 119 | } 120 | 121 | getFieldsRecord(): Record { 122 | if (!this._resolvedFields) { 123 | const fields: Record = {}; 124 | 125 | const fieldMap = 126 | typeof this._fields === "function" 127 | ? this._fields() 128 | : this._fields || {}; 129 | 130 | for (const fieldName of Object.keys(fieldMap)) { 131 | const field = fieldMap[fieldName]; 132 | 133 | if (isValueType(field)) { 134 | fields[fieldName] = { type: field }; 135 | } else if (Array.isArray(field)) { 136 | fields[fieldName] = { type: new ArrayType(field[0]) }; 137 | } else { 138 | fields[fieldName] = field; 139 | } 140 | } 141 | 142 | this._resolvedFields = fields; 143 | } 144 | 145 | return this._resolvedFields; 146 | } 147 | 148 | getFieldEntries(): [string, ObjectFieldType][] { 149 | if (!this._resolvedFieldEntries) { 150 | const entries: [string, ObjectFieldType][] = []; 151 | const fields = this.getFieldsRecord(); 152 | for (const fieldName of Object.keys(fields)) { 153 | entries.push([fieldName, fields[fieldName]]); 154 | } 155 | this._resolvedFieldEntries = entries; 156 | } 157 | return this._resolvedFieldEntries; 158 | } 159 | 160 | getField(name: string): ObjectFieldType | undefined { 161 | const fields = this.getFieldsRecord(); 162 | 163 | if (!fields[name]) { 164 | const index = name.indexOf("("); 165 | if (index !== -1) { 166 | const nameWithoutArgs = name.slice(0, index); 167 | if (fields[nameWithoutArgs] && fields[nameWithoutArgs].arguments) { 168 | name = nameWithoutArgs; 169 | } 170 | } 171 | } 172 | 173 | return fields[name]; 174 | } 175 | 176 | isOfType(value: unknown): boolean { 177 | return this._isOfType ? this._isOfType(value) : isObject(value); 178 | } 179 | } 180 | 181 | export interface UnionTypeConfig { 182 | name?: string; 183 | types: ValueType[]; 184 | resolveType?: (value: any) => ValueType | undefined; 185 | id?: IdFunction; 186 | } 187 | 188 | export class UnionType { 189 | name?: string; 190 | types: ValueType[]; 191 | _resolveType?: (value: any) => ValueType | undefined; 192 | id?: IdFunction; 193 | 194 | constructor(config: UnionTypeConfig | ValueType[]) { 195 | if (Array.isArray(config)) { 196 | this.types = config; 197 | } else { 198 | this.name = config.name; 199 | this.types = config.types; 200 | this._resolveType = config.resolveType; 201 | this.id = config.id; 202 | } 203 | } 204 | 205 | resolveType(value: unknown): ValueType | undefined { 206 | let resolvedType; 207 | 208 | if (this._resolveType) { 209 | resolvedType = this._resolveType(value); 210 | } 211 | 212 | if (!resolvedType) { 213 | resolvedType = this.types.find((type) => type.isOfType(value)); 214 | } 215 | 216 | return resolvedType; 217 | } 218 | 219 | isOfType(value: unknown): boolean { 220 | return Boolean(this.resolveType(value)); 221 | } 222 | } 223 | 224 | export interface StringTypeConfig { 225 | name?: string; 226 | const?: string; 227 | id?: IdFunction; 228 | } 229 | 230 | export class StringType { 231 | name?: string; 232 | const?: string; 233 | id?: IdFunction; 234 | 235 | constructor(config?: StringTypeConfig | string) { 236 | if (typeof config === "string") { 237 | this.const = config; 238 | } else if (config) { 239 | this.name = config.name; 240 | this.const = config.const; 241 | this.id = config.id; 242 | } 243 | } 244 | 245 | isOfType(value: unknown): boolean { 246 | return this.const ? value === this.const : typeof value === "string"; 247 | } 248 | } 249 | 250 | export interface NumberTypeConfig { 251 | name?: string; 252 | const?: number; 253 | id?: IdFunction; 254 | } 255 | 256 | export class NumberType { 257 | name?: string; 258 | const?: number; 259 | id?: IdFunction; 260 | 261 | constructor(config?: NumberTypeConfig | number) { 262 | if (typeof config === "number") { 263 | this.const = config; 264 | } else if (config) { 265 | this.name = config.name; 266 | this.const = config.const; 267 | this.id = config.id; 268 | } 269 | } 270 | 271 | isOfType(value: unknown): boolean { 272 | return this.const ? value === this.const : typeof value === "number"; 273 | } 274 | } 275 | 276 | export interface BooleanTypeConfig { 277 | name?: string; 278 | const?: boolean; 279 | id?: IdFunction; 280 | } 281 | 282 | export class BooleanType { 283 | name?: string; 284 | const?: boolean; 285 | id?: IdFunction; 286 | 287 | constructor(config?: BooleanTypeConfig | boolean) { 288 | if (typeof config === "boolean") { 289 | this.const = config; 290 | } else if (config) { 291 | this.name = config.name; 292 | this.const = config.const; 293 | this.id = config.id; 294 | } 295 | } 296 | 297 | isOfType(value: unknown): boolean { 298 | return this.const ? value === this.const : typeof value === "boolean"; 299 | } 300 | } 301 | 302 | export type ValueType = 303 | | ArrayType 304 | | BooleanType 305 | | NonNullableType 306 | | NumberType 307 | | ObjectType 308 | | StringType 309 | | UnionType; 310 | 311 | export function isArrayType(value: unknown): value is ArrayType { 312 | return value instanceof ArrayType; 313 | } 314 | 315 | export function isBooleanType(value: unknown): value is BooleanType { 316 | return value instanceof BooleanType; 317 | } 318 | 319 | export function isNonNullableType(value: unknown): value is NonNullableType { 320 | return value instanceof NonNullableType; 321 | } 322 | 323 | export function isNumberType(value: unknown): value is NumberType { 324 | return value instanceof NumberType; 325 | } 326 | 327 | export function isObjectType(value: unknown): value is ObjectType { 328 | return value instanceof ObjectType; 329 | } 330 | 331 | export function isStringType(value: unknown): value is StringType { 332 | return value instanceof StringType; 333 | } 334 | 335 | export function isUnionType(value: unknown): value is UnionType { 336 | return value instanceof UnionType; 337 | } 338 | 339 | export function resolveNamedType(type: ValueType): ValueType | undefined { 340 | if (type.name) { 341 | return type; 342 | } 343 | 344 | const unwrappedType = unwrapType(type); 345 | 346 | if (unwrappedType) { 347 | return resolveNamedType(unwrappedType); 348 | } 349 | } 350 | 351 | export function unwrapType( 352 | type: ValueType, 353 | value?: unknown 354 | ): ValueType | undefined { 355 | if (isNonNullableType(type)) { 356 | return type.ofType; 357 | } else if (isUnionType(type)) { 358 | return type.resolveType(value); 359 | } 360 | } 361 | 362 | export function resolveWrappedType( 363 | type: ValueType, 364 | value: unknown 365 | ): ValueType | undefined { 366 | const resolvedType = unwrapType(type, value); 367 | return resolvedType ? resolveWrappedType(resolvedType, value) : type; 368 | } 369 | 370 | function isValueType(value: unknown): value is ValueType { 371 | return ( 372 | isArrayType(value) || 373 | isBooleanType(value) || 374 | isNonNullableType(value) || 375 | isNumberType(value) || 376 | isObjectType(value) || 377 | isStringType(value) || 378 | isUnionType(value) 379 | ); 380 | } 381 | 382 | export const schema = { 383 | array(config?: ArrayTypeConfig | ValueType): ArrayType { 384 | return new ArrayType(config); 385 | }, 386 | boolean(config?: BooleanTypeConfig): BooleanType { 387 | return new BooleanType(config); 388 | }, 389 | nonNullable(config: NonNullableTypeConfig | ValueType): NonNullableType { 390 | return new NonNullableType(config); 391 | }, 392 | number(config?: NumberTypeConfig): NumberType { 393 | return new NumberType(config); 394 | }, 395 | object(config?: ObjectTypeConfig): ObjectType { 396 | return new ObjectType(config); 397 | }, 398 | string(config?: StringTypeConfig | string): StringType { 399 | return new StringType(config); 400 | }, 401 | union(config: UnionTypeConfig | ValueType[]): UnionType { 402 | return new UnionType(config); 403 | }, 404 | }; 405 | -------------------------------------------------------------------------------- /src/operations/write.ts: -------------------------------------------------------------------------------- 1 | import { 2 | isArrayType, 3 | isObjectType, 4 | ValueType, 5 | resolveWrappedType, 6 | } from "../schema/types"; 7 | import type { 8 | EntitiesRecord, 9 | Entity, 10 | Reference, 11 | InvalidField, 12 | PlainObjectWithMeta, 13 | } from "../types"; 14 | import { 15 | createReference, 16 | isObjectWithMeta, 17 | identifyByData, 18 | identifyById, 19 | identifyByType, 20 | isReference, 21 | } from "../utils/cache"; 22 | import { createRecord, isObject } from "../utils/data"; 23 | import type { Cache } from "../Cache"; 24 | import { updateEntities } from "./shared"; 25 | import { isValid, maybeGetObjectField } from "../schema/utils"; 26 | import { ErrorCode, invariant } from "../utils/invariant"; 27 | import { 28 | DocumentNode, 29 | FieldNode, 30 | InlineFragmentNode, 31 | NodeType, 32 | SelectionSetNode, 33 | } from "../language/ast"; 34 | import { 35 | createDocument, 36 | createField, 37 | createInlineFragment, 38 | createSelectionSet, 39 | } from "../language/utils"; 40 | 41 | interface WriteOptions { 42 | data: unknown; 43 | expiresAt?: number; 44 | id?: unknown; 45 | onlyWriteKnownFields?: boolean; 46 | } 47 | 48 | export interface WriteResult { 49 | entityID: string; 50 | invalidFields?: InvalidField[]; 51 | selector?: DocumentNode; 52 | updatedEntityIDs?: string[]; 53 | } 54 | 55 | interface WriteContext { 56 | cache: Cache; 57 | entities: EntitiesRecord; 58 | expiresAt: number; 59 | incomingParents: unknown[]; 60 | invalidFields: InvalidField[]; 61 | onlyWriteKnownFields: boolean | undefined; 62 | optimistic?: boolean; 63 | path: (string | number)[]; 64 | rootEntityID: string; 65 | } 66 | 67 | export function executeWrite( 68 | cache: Cache, 69 | type: ValueType, 70 | optimistic: boolean, 71 | options: WriteOptions 72 | ): WriteResult { 73 | let entityID; 74 | 75 | if (options.id !== undefined) { 76 | entityID = identifyById(type, options.id); 77 | } else if (options.data !== undefined) { 78 | entityID = identifyByData(type, options.data); 79 | } 80 | 81 | // Fallback to the type name 82 | if (!entityID) { 83 | entityID = identifyByType(type)!; 84 | } 85 | 86 | const existingEntity = cache.get(entityID, optimistic); 87 | 88 | const ctx: WriteContext = { 89 | cache, 90 | entities: createRecord(), 91 | expiresAt: typeof options.expiresAt === "number" ? options.expiresAt : -1, 92 | incomingParents: [], 93 | invalidFields: [], 94 | optimistic, 95 | path: [], 96 | rootEntityID: entityID, 97 | onlyWriteKnownFields: options.onlyWriteKnownFields, 98 | }; 99 | 100 | const selectionSet = createSelectionSet(); 101 | 102 | processIncoming(ctx, type, existingEntity, options.data, selectionSet); 103 | 104 | const updatedEntityIDs = updateEntities(cache, ctx.entities, optimistic); 105 | 106 | const result: WriteResult = { 107 | entityID, 108 | }; 109 | 110 | if (updatedEntityIDs.length) { 111 | result.updatedEntityIDs = updatedEntityIDs; 112 | } 113 | 114 | if (ctx.invalidFields.length) { 115 | result.invalidFields = ctx.invalidFields; 116 | } 117 | 118 | if (selectionSet.selections.length) { 119 | const document = createDocument(); 120 | document.definitions = [selectionSet]; 121 | result.selector = document; 122 | } 123 | 124 | return result; 125 | } 126 | 127 | function processIncoming( 128 | ctx: WriteContext, 129 | type: ValueType | undefined, 130 | existing: unknown, 131 | incoming: unknown, 132 | selectionSet: SelectionSetNode 133 | ): unknown { 134 | let entity: Entity | undefined; 135 | let entityID: string | undefined; 136 | let entityRef: Reference | undefined; 137 | 138 | if (type) { 139 | if (!ctx.path.length) { 140 | entityID = ctx.rootEntityID; 141 | } else if (type) { 142 | entityID = identifyByData(type, incoming); 143 | } 144 | 145 | if (entityID) { 146 | entity = ctx.entities[entityID]; 147 | 148 | if (!entity) { 149 | const existingEntity = ctx.cache.get(entityID, ctx.optimistic); 150 | 151 | if (existingEntity) { 152 | entity = { ...existingEntity }; 153 | } else { 154 | entity = { 155 | expiresAt: -1, 156 | id: entityID, 157 | invalidated: false, 158 | value: undefined, 159 | }; 160 | } 161 | 162 | // If the incoming data is an object the expiry dates will be written to the fields 163 | if (!isObject(incoming)) { 164 | entity.expiresAt = ctx.expiresAt; 165 | } 166 | 167 | // Always remove invalidation when writing to an entity 168 | entity.invalidated = false; 169 | } 170 | 171 | ctx.entities[entityID] = entity; 172 | existing = entity.value; 173 | entityRef = createReference(entityID); 174 | } 175 | 176 | if (!isValid(type, incoming)) { 177 | addInvalidField(ctx, incoming); 178 | } 179 | 180 | type = resolveWrappedType(type, incoming); 181 | 182 | if (isObjectType(type) && type.name) { 183 | const inlineFragment = createInlineFragment(type.name); 184 | selectionSet.selections.push(inlineFragment); 185 | selectionSet = inlineFragment.selectionSet; 186 | } 187 | } 188 | 189 | let result = incoming; 190 | 191 | if (isObject(incoming)) { 192 | if (isCircularEntity(ctx, incoming, entityRef)) { 193 | return entityRef; 194 | } 195 | 196 | let resultObj: Reference | PlainObjectWithMeta; 197 | 198 | if (isReference(incoming)) { 199 | resultObj = incoming; 200 | } else { 201 | resultObj = { 202 | ___invalidated: {}, 203 | ___expiresAt: {}, 204 | }; 205 | 206 | const existingObj = isObjectWithMeta(existing) ? existing : undefined; 207 | 208 | for (const fieldName of Object.keys(incoming)) { 209 | ctx.path.push(fieldName); 210 | 211 | if (shouldWriteField(ctx, type, fieldName)) { 212 | const objectField = maybeGetObjectField(type, fieldName); 213 | 214 | resultObj.___expiresAt[fieldName] = ctx.expiresAt; 215 | resultObj.___invalidated[fieldName] = false; 216 | 217 | const existingFieldValue = existingObj && existingObj[fieldName]; 218 | 219 | const fieldSelectionSet = createSelectionSet(); 220 | 221 | ctx.incomingParents.push(incoming); 222 | let newFieldValue = processIncoming( 223 | ctx, 224 | objectField && objectField.type, 225 | existingFieldValue, 226 | incoming[fieldName], 227 | fieldSelectionSet 228 | ); 229 | ctx.incomingParents.pop(); 230 | 231 | if (objectField && objectField.write) { 232 | newFieldValue = objectField.write( 233 | newFieldValue, 234 | existingFieldValue 235 | ); 236 | } 237 | 238 | resultObj[fieldName] = newFieldValue; 239 | 240 | const fieldNode = createField(fieldName); 241 | 242 | if (fieldSelectionSet.selections.length) { 243 | fieldNode.selectionSet = fieldSelectionSet; 244 | } 245 | 246 | selectionSet.selections.push(fieldNode); 247 | } 248 | 249 | // Update existing value as the current entity might also have been nested in the current field 250 | if (entity) { 251 | existing = entity.value; 252 | } 253 | 254 | ctx.path.pop(); 255 | } 256 | } 257 | 258 | if (isObjectType(type) && type.write) { 259 | resultObj = type.write(resultObj, existing); 260 | } else if ( 261 | entity && 262 | isObjectWithMeta(existing) && 263 | isObjectWithMeta(resultObj) 264 | ) { 265 | // Entities can be safely merged 266 | resultObj = { 267 | ...existing, 268 | ...resultObj, 269 | ___expiresAt: { 270 | ...existing.___expiresAt, 271 | ...resultObj.___expiresAt, 272 | }, 273 | ___invalidated: { 274 | ...existing.___invalidated, 275 | ...resultObj.___invalidated, 276 | }, 277 | }; 278 | } 279 | 280 | result = resultObj; 281 | } else if (Array.isArray(incoming)) { 282 | if (isCircularEntity(ctx, incoming, entityRef)) { 283 | return entityRef; 284 | } 285 | 286 | let resultArray: unknown[] = []; 287 | const existingArray = Array.isArray(existing) ? existing : undefined; 288 | const ofType = isArrayType(type) ? type.ofType : undefined; 289 | 290 | for (let i = 0; i < incoming.length; i++) { 291 | ctx.path.push(i); 292 | 293 | const fieldSelectionSet = createSelectionSet(); 294 | 295 | ctx.incomingParents.push(incoming); 296 | const item = processIncoming( 297 | ctx, 298 | ofType, 299 | existingArray && existingArray[i], 300 | incoming[i], 301 | fieldSelectionSet 302 | ); 303 | ctx.incomingParents.pop(); 304 | 305 | resultArray.push(item); 306 | extendSelectionSet(selectionSet, fieldSelectionSet); 307 | 308 | // Update existing value as the current entity might also have been nested in the current field 309 | if (entity) { 310 | existing = entity.value; 311 | } 312 | 313 | ctx.path.pop(); 314 | } 315 | 316 | if (isArrayType(type) && type.write) { 317 | resultArray = type.write(resultArray, existing); 318 | } 319 | 320 | result = resultArray; 321 | } 322 | 323 | if (entity) { 324 | entity.value = result; 325 | result = entityRef; 326 | } 327 | 328 | return result; 329 | } 330 | 331 | function addInvalidField(ctx: WriteContext, value: unknown) { 332 | ctx.invalidFields.push({ path: [...ctx.path], value }); 333 | } 334 | 335 | function shouldWriteField( 336 | ctx: WriteContext, 337 | type: ValueType | undefined, 338 | name: string 339 | ): boolean { 340 | if (!ctx.onlyWriteKnownFields || !isObjectType(type)) { 341 | return true; 342 | } 343 | 344 | return Boolean(type.getField(name) || !type.getFieldEntries().length); 345 | } 346 | 347 | function isCircularEntity( 348 | ctx: WriteContext, 349 | incoming: unknown, 350 | ref: Reference | undefined 351 | ): boolean { 352 | if (ctx.incomingParents.includes(incoming)) { 353 | invariant( 354 | ref, 355 | process.env.NODE_ENV === "production" 356 | ? ErrorCode.WRITE_CIRCULAR_DATA 357 | : `Cannot write non-entity data with circular references` 358 | ); 359 | 360 | return true; 361 | } 362 | 363 | return false; 364 | } 365 | 366 | function extendSelectionSet( 367 | target: SelectionSetNode, 368 | source: SelectionSetNode 369 | ): void { 370 | for (const sourceSelection of source.selections) { 371 | let targetSelection: InlineFragmentNode | FieldNode | undefined; 372 | 373 | if (sourceSelection.kind === NodeType.InlineFragment) { 374 | targetSelection = target.selections.find( 375 | (selection) => 376 | selection.kind === NodeType.InlineFragment && 377 | selection.typeCondition!.name.value === 378 | sourceSelection.typeCondition!.name.value 379 | ) as InlineFragmentNode | undefined; 380 | } else if (sourceSelection.kind === NodeType.Field) { 381 | targetSelection = target.selections.find( 382 | (selection) => 383 | selection.kind === NodeType.Field && 384 | selection.name.value === sourceSelection.name.value 385 | ) as FieldNode | undefined; 386 | } else { 387 | continue; 388 | } 389 | 390 | if (!targetSelection) { 391 | target.selections.push(sourceSelection); 392 | } else if (!targetSelection.selectionSet) { 393 | targetSelection.selectionSet = sourceSelection.selectionSet; 394 | } else if (targetSelection.selectionSet && sourceSelection.selectionSet) { 395 | extendSelectionSet( 396 | targetSelection.selectionSet, 397 | sourceSelection.selectionSet 398 | ); 399 | } 400 | } 401 | } 402 | -------------------------------------------------------------------------------- /src/__tests__/optimistic.spec.ts: -------------------------------------------------------------------------------- 1 | import { Cache, cql, ReadResult, schema } from ".."; 2 | 3 | describe("Optimistic", () => { 4 | it("should read optimistic data by default", () => { 5 | const Child = schema.object({ name: "Child" }); 6 | const Parent = schema.object({ name: "Parent", fields: { child: Child } }); 7 | const cache = new Cache({ types: [Parent] }); 8 | cache.write({ type: "Parent", data: { child: { id: "1", a: "a" } } }); 9 | cache.write({ 10 | type: "Parent", 11 | data: { child: { id: "1", b: "b" } }, 12 | optimistic: true, 13 | }); 14 | const result = cache.read({ 15 | type: "Parent", 16 | select: cql`{ child { b } }`, 17 | }); 18 | expect(result!.data).toEqual({ child: { b: "b" } }); 19 | }); 20 | 21 | it("should not return optimistically deleted entities", () => { 22 | const Child = schema.object({ name: "Child" }); 23 | const Parent = schema.object({ name: "Parent", fields: { child: Child } }); 24 | const cache = new Cache({ types: [Parent] }); 25 | cache.write({ type: "Parent", data: { child: { id: "1", a: "a" } } }); 26 | cache.read({ type: "Parent", select: cql`{ child { a } }` }); 27 | cache.delete({ type: "Parent", optimistic: true }); 28 | const parentResult = cache.read({ 29 | type: "Parent", 30 | select: cql`{ child { a } }`, 31 | }); 32 | const childResult = cache.read({ 33 | type: "Child", 34 | id: "1", 35 | select: cql`{ a }`, 36 | }); 37 | expect(parentResult).toBeUndefined(); 38 | expect(childResult!.data).toEqual({ a: "a" }); 39 | }); 40 | 41 | it("should return optimistically deleted entities on non-optimistic reads", () => { 42 | const Child = schema.object({ name: "Child" }); 43 | const Parent = schema.object({ name: "Parent", fields: { child: Child } }); 44 | const cache = new Cache({ types: [Parent] }); 45 | cache.write({ type: "Parent", data: { child: { id: "1", a: "a" } } }); 46 | cache.delete({ type: "Parent", optimistic: true }); 47 | const parentResult = cache.read({ 48 | type: "Parent", 49 | select: cql`{ child { a } }`, 50 | optimistic: false, 51 | }); 52 | expect(parentResult!.data).toEqual({ child: { a: "a" } }); 53 | }); 54 | 55 | it("should not return optimistically deleted fields", () => { 56 | const Child = schema.object({ name: "Child" }); 57 | const Parent = schema.object({ name: "Parent", fields: { child: Child } }); 58 | const cache = new Cache({ types: [Parent] }); 59 | cache.write({ type: "Parent", data: { child: { id: "1", a: "a" } } }); 60 | cache.delete({ 61 | type: "Parent", 62 | optimistic: true, 63 | select: cql`{ child { a } }`, 64 | }); 65 | const parentResult = cache.read({ 66 | type: "Parent", 67 | select: cql`{ child { a } }`, 68 | }); 69 | const childResult = cache.read({ 70 | type: "Child", 71 | id: "1", 72 | select: cql`{ id a }`, 73 | }); 74 | expect(parentResult!.data).toEqual({ child: {} }); 75 | expect(childResult!.data).toEqual({ id: "1" }); 76 | }); 77 | 78 | it("should return optimistically deleted fields on non-optimistic reads", () => { 79 | const Child = schema.object({ name: "Child" }); 80 | const Parent = schema.object({ name: "Parent", fields: { child: Child } }); 81 | const cache = new Cache({ types: [Parent] }); 82 | cache.write({ type: "Parent", data: { child: { id: "1", a: "a" } } }); 83 | cache.delete({ 84 | type: "Parent", 85 | optimistic: true, 86 | select: cql`{ child { a } }`, 87 | }); 88 | const parentResult = cache.read({ 89 | type: "Parent", 90 | optimistic: false, 91 | select: cql`{ child { a } }`, 92 | }); 93 | const childResult = cache.read({ 94 | type: "Child", 95 | id: "1", 96 | optimistic: false, 97 | select: cql`{ id a }`, 98 | }); 99 | expect(parentResult!.data).toEqual({ child: { a: "a" } }); 100 | expect(childResult!.data).toEqual({ id: "1", a: "a" }); 101 | }); 102 | 103 | it("should be able to read non-optimistic data", () => { 104 | const Child = schema.object({ name: "Child" }); 105 | const Parent = schema.object({ name: "Parent", fields: { child: Child } }); 106 | const cache = new Cache({ types: [Parent] }); 107 | cache.write({ type: "Parent", data: { child: { id: "1", a: "a" } } }); 108 | cache.write({ 109 | type: "Parent", 110 | data: { child: { id: "1", a: "aa" } }, 111 | optimistic: true, 112 | }); 113 | const result = cache.read({ 114 | type: "Parent", 115 | select: cql`{ child { a } }`, 116 | optimistic: false, 117 | }); 118 | expect(result!.data).toEqual({ child: { a: "a" } }); 119 | }); 120 | 121 | it("should be able to apply an optimistic update function", () => { 122 | const Child = schema.object({ name: "Child" }); 123 | const Parent = schema.object({ name: "Parent", fields: { child: Child } }); 124 | const cache = new Cache({ types: [Parent] }); 125 | cache.write({ type: "Parent", data: { child: { id: "1", a: "a" } } }); 126 | cache.addOptimisticUpdate(() => { 127 | cache.write({ type: "Parent", data: { child: { id: "1", b: "b" } } }); 128 | }); 129 | const result = cache.read({ 130 | type: "Parent", 131 | select: cql`{ child { b } }`, 132 | }); 133 | expect(result!.data).toEqual({ child: { b: "b" } }); 134 | }); 135 | 136 | it("should be able to apply multiple optimistic update functions", () => { 137 | const Child = schema.object({ name: "Child" }); 138 | const Parent = schema.object({ name: "Parent", fields: { child: Child } }); 139 | const cache = new Cache({ types: [Parent] }); 140 | cache.write({ type: "Parent", data: { child: { id: "1", a: "a" } } }); 141 | cache.addOptimisticUpdate(() => { 142 | cache.write({ type: "Parent", data: { child: { id: "1", b: "b" } } }); 143 | }); 144 | cache.addOptimisticUpdate(() => { 145 | cache.write({ type: "Parent", data: { child: { id: "1", c: "c" } } }); 146 | }); 147 | const result = cache.read({ 148 | type: "Parent", 149 | select: cql`{ child { a b c } }`, 150 | }); 151 | expect(result!.data).toEqual({ child: { a: "a", b: "b", c: "c" } }); 152 | }); 153 | 154 | it("should be able to remove an optimistic update", () => { 155 | const Child = schema.object({ name: "Child" }); 156 | const Parent = schema.object({ name: "Parent", fields: { child: Child } }); 157 | const cache = new Cache({ types: [Parent] }); 158 | cache.write({ type: "Parent", data: { child: { id: "1", a: "a" } } }); 159 | const { dispose } = cache.addOptimisticUpdate(() => { 160 | cache.write({ type: "Parent", data: { child: { id: "1", b: "b" } } }); 161 | }); 162 | dispose(); 163 | const result = cache.read({ 164 | type: "Parent", 165 | select: cql`{ child { b } }`, 166 | }); 167 | expect(result!.data).toEqual({ child: {} }); 168 | }); 169 | 170 | it("should re-apply optimistic update functions on non-optimistic write", () => { 171 | const Child = schema.object({ name: "Child" }); 172 | const Parent = schema.object({ name: "Parent", fields: { child: Child } }); 173 | const cache = new Cache({ types: [Parent] }); 174 | cache.write({ type: "Parent", data: { child: { id: "1", a: "a" } } }); 175 | cache.addOptimisticUpdate(() => { 176 | cache.write({ type: "Parent", data: { child: { id: "1", b: "b" } } }); 177 | }); 178 | cache.write({ type: "Parent", data: { child: { id: "1", c: "c" } } }); 179 | const result = cache.read({ 180 | type: "Parent", 181 | select: cql`{ child { b c } }`, 182 | }); 183 | expect(result!.data).toEqual({ child: { b: "b", c: "c" } }); 184 | }); 185 | 186 | it("should be able to write and delete in optimistic update functions", () => { 187 | const Child = schema.object({ name: "Child" }); 188 | const Parent = schema.object({ name: "Parent", fields: { child: Child } }); 189 | const cache = new Cache({ types: [Parent] }); 190 | cache.write({ type: "Parent", data: { child: { id: "1", a: "a" } } }); 191 | cache.addOptimisticUpdate(() => { 192 | cache.write({ type: "Parent", data: { child: { id: "1", b: "b" } } }); 193 | cache.delete({ type: "Child", id: "1" }); 194 | }); 195 | const result = cache.read({ 196 | type: "Parent", 197 | select: cql`{ child { b } }`, 198 | }); 199 | expect(result!.data).toEqual({ child: undefined }); 200 | }); 201 | 202 | it("should be able to read other optimistic updates in the update functions", () => { 203 | const Child = schema.object({ name: "Child" }); 204 | const Parent = schema.object({ name: "Parent", fields: { child: Child } }); 205 | const cache = new Cache({ types: [Parent] }); 206 | cache.write({ type: "Parent", data: { child: { id: "1", a: "a" } } }); 207 | cache.addOptimisticUpdate(() => { 208 | cache.delete({ type: "Child", id: "1" }); 209 | }); 210 | cache.addOptimisticUpdate(() => { 211 | const result = cache.read({ type: "Child", id: "1" }); 212 | if (result) { 213 | cache.write({ type: "Parent", data: { child: { id: "1", c: "c" } } }); 214 | } 215 | }); 216 | const result = cache.read({ 217 | type: "Parent", 218 | select: cql`{ child { a b c } }`, 219 | }); 220 | expect(result!.data).toEqual({ child: undefined }); 221 | }); 222 | 223 | it("should be able to remove one update function", () => { 224 | const Child = schema.object({ name: "Child" }); 225 | const Parent = schema.object({ name: "Parent", fields: { child: Child } }); 226 | const cache = new Cache({ types: [Parent] }); 227 | cache.write({ type: "Parent", data: { child: { id: "1", a: "a" } } }); 228 | const { id: id1 } = cache.addOptimisticUpdate(() => { 229 | cache.delete({ type: "Child", id: "1" }); 230 | }); 231 | const { id: id2 } = cache.addOptimisticUpdate(() => { 232 | const result = cache.read({ type: "Child", id: "1" }); 233 | if (result) { 234 | cache.write({ type: "Parent", data: { child: { id: "1", c: "c" } } }); 235 | } 236 | }); 237 | cache.removeOptimisticUpdate(id1); 238 | const result1 = cache.read({ 239 | type: "Parent", 240 | select: cql`{ child { a b c } }`, 241 | }); 242 | expect(result1!.data).toEqual({ child: { a: "a", c: "c" } }); 243 | cache.removeOptimisticUpdate(id2); 244 | const result2 = cache.read({ 245 | type: "Parent", 246 | select: cql`{ child { a b c } }`, 247 | }); 248 | expect(result2!.data).toEqual({ child: { a: "a" } }); 249 | }); 250 | 251 | it("should notify once when doing multiple writes in an update function", () => { 252 | const Child = schema.object({ name: "Child" }); 253 | const Parent = schema.object({ name: "Parent", fields: { child: Child } }); 254 | const cache = new Cache({ types: [Parent] }); 255 | const callback = jest.fn(); 256 | cache.write({ type: "Parent", data: { child: { id: "1", a: "a" } } }); 257 | cache.watch({ type: "Parent", select: cql`{ child { a b c } }`, callback }); 258 | cache.addOptimisticUpdate(() => { 259 | cache.write({ type: "Parent", data: { child: { id: "1", b: "b" } } }); 260 | cache.write({ type: "Parent", data: { child: { id: "1", c: "c" } } }); 261 | }); 262 | const prevResult: Partial = { 263 | data: { child: { a: "a" } }, 264 | }; 265 | const newResult: Partial = { 266 | data: { child: { a: "a", b: "b", c: "c" } }, 267 | }; 268 | expect(callback).toHaveBeenCalledWith( 269 | expect.objectContaining(newResult), 270 | expect.objectContaining(prevResult) 271 | ); 272 | }); 273 | 274 | it("should notify once when applying multiple update functions", () => { 275 | const Child = schema.object({ name: "Child" }); 276 | const Parent = schema.object({ name: "Parent", fields: { child: Child } }); 277 | const cache = new Cache({ types: [Parent] }); 278 | const callback = jest.fn(); 279 | cache.write({ type: "Parent", data: { child: { id: "1", a: "a" } } }); 280 | cache.addOptimisticUpdate(() => { 281 | cache.write({ type: "Parent", data: { child: { id: "1", b: "b" } } }); 282 | }); 283 | cache.addOptimisticUpdate(() => { 284 | cache.write({ type: "Parent", data: { child: { id: "1", c: "c" } } }); 285 | }); 286 | cache.watch({ 287 | type: "Parent", 288 | select: cql`{ child { a b c d } }`, 289 | callback, 290 | }); 291 | cache.write({ type: "Parent", data: { child: { id: "1", d: "d" } } }); 292 | const prevResult: Partial = { 293 | data: { child: { a: "a", b: "b", c: "c" } }, 294 | }; 295 | const newResult: Partial = { 296 | data: { child: { a: "a", b: "b", c: "c", d: "d" } }, 297 | }; 298 | expect(callback).toHaveBeenCalledTimes(1); 299 | expect(callback).toHaveBeenCalledWith( 300 | expect.objectContaining(newResult), 301 | expect.objectContaining(prevResult) 302 | ); 303 | }); 304 | }); 305 | -------------------------------------------------------------------------------- /src/__tests__/validation.spec.ts: -------------------------------------------------------------------------------- 1 | import { Cache, cql, schema } from ".."; 2 | 3 | describe("Validation", () => { 4 | describe("writing", () => { 5 | it("should not report invalid fields if the incoming data matches the schema", () => { 6 | const Type = schema.string({ name: "Type" }); 7 | const cache = new Cache({ types: [Type] }); 8 | const { invalidFields } = cache.write({ type: "Type", data: "a" }); 9 | expect(invalidFields).toBeUndefined(); 10 | }); 11 | 12 | it("should report invalid fields if the incoming data does not match the schema", () => { 13 | const Type = schema.string({ name: "Type" }); 14 | const cache = new Cache({ types: [Type] }); 15 | const { invalidFields } = cache.write({ type: "Type", data: 1 }); 16 | expect(invalidFields).toEqual([{ path: [], value: 1 }]); 17 | }); 18 | 19 | it("should report invalid fields if the incoming required data is missing", () => { 20 | const Type = schema.nonNullable({ 21 | name: "Type", 22 | ofType: schema.string(), 23 | }); 24 | const cache = new Cache({ types: [Type] }); 25 | const { invalidFields } = cache.write({ type: "Type", data: undefined }); 26 | expect(invalidFields).toEqual([{ path: [], value: undefined }]); 27 | }); 28 | 29 | it("should not write fields unknown to the schema if onlyWriteKnownFields is enabled", () => { 30 | const Type = schema.object({ 31 | name: "Type", 32 | fields: { a: schema.string() }, 33 | }); 34 | const cache = new Cache({ types: [Type], onlyWriteKnownFields: true }); 35 | cache.write({ type: "Type", data: { a: "a", b: "b" } }); 36 | const readResult = cache.read({ type: "Type" }); 37 | expect(readResult!.data).toEqual({ a: "a" }); 38 | }); 39 | 40 | it("should write fields unknown to the schema if an object does not have fields defined and onlyWriteKnownFields is enabled", () => { 41 | const Type = schema.object({ name: "Type" }); 42 | const cache = new Cache({ types: [Type], onlyWriteKnownFields: true }); 43 | cache.write({ type: "Type", data: { a: "a", b: "b" } }); 44 | const readResult = cache.read({ type: "Type" }); 45 | expect(readResult!.data).toEqual({ a: "a", b: "b" }); 46 | }); 47 | 48 | it("should not report invalid fields if the incoming data has additional fields", () => { 49 | const Type = schema.object({ 50 | name: "Type", 51 | fields: { id: schema.number() }, 52 | }); 53 | const cache = new Cache({ types: [Type] }); 54 | const { invalidFields } = cache.write({ 55 | type: "Type", 56 | data: { id: 1, a: "a" }, 57 | }); 58 | expect(invalidFields).toBeUndefined(); 59 | }); 60 | 61 | it("should not report invalid fields if the incoming data has a valid boolean", () => { 62 | const Type = schema.object({ 63 | name: "Type", 64 | fields: { a: schema.boolean() }, 65 | }); 66 | const cache = new Cache({ types: [Type] }); 67 | const { invalidFields } = cache.write({ 68 | type: "Type", 69 | data: { a: true }, 70 | }); 71 | expect(invalidFields).toBeUndefined(); 72 | }); 73 | 74 | it("should report invalid fields if the incoming data has an invalid boolean", () => { 75 | const Type = schema.object({ 76 | name: "Type", 77 | fields: { a: schema.boolean() }, 78 | }); 79 | const cache = new Cache({ types: [Type] }); 80 | const { invalidFields } = cache.write({ type: "Type", data: { a: 0 } }); 81 | expect(invalidFields).toEqual([{ path: ["a"], value: 0 }]); 82 | }); 83 | 84 | it("should not report invalid fields if the incoming data has a valid const string", () => { 85 | const Type = schema.object({ 86 | name: "Type", 87 | fields: { a: schema.string({ const: "A" }) }, 88 | }); 89 | const cache = new Cache({ types: [Type] }); 90 | const { invalidFields } = cache.write({ type: "Type", data: { a: "A" } }); 91 | expect(invalidFields).toBeUndefined(); 92 | }); 93 | 94 | it("should report invalid fields if the incoming data has an invalid const string", () => { 95 | const Type = schema.object({ 96 | name: "Type", 97 | fields: { a: schema.string({ const: "A" }) }, 98 | }); 99 | const cache = new Cache({ types: [Type] }); 100 | const { invalidFields } = cache.write({ type: "Type", data: { a: "B" } }); 101 | expect(invalidFields).toEqual([{ path: ["a"], value: "B" }]); 102 | }); 103 | 104 | it("should not report invalid fields if the incoming data has a valid union", () => { 105 | const Type = schema.object({ 106 | name: "Type", 107 | fields: { 108 | a: schema.union({ 109 | types: [ 110 | schema.string({ const: "A" }), 111 | schema.string({ const: "B" }), 112 | ], 113 | }), 114 | }, 115 | }); 116 | const cache = new Cache({ types: [Type] }); 117 | const { invalidFields } = cache.write({ type: "Type", data: { a: "B" } }); 118 | expect(invalidFields).toBeUndefined(); 119 | }); 120 | 121 | it("should report invalid fields if the incoming data has an invalid union", () => { 122 | const Type = schema.object({ 123 | name: "Type", 124 | fields: { 125 | a: schema.union([schema.string("A"), schema.string("B")]), 126 | }, 127 | }); 128 | const cache = new Cache({ types: [Type] }); 129 | const { invalidFields } = cache.write({ type: "Type", data: { a: "C" } }); 130 | expect(invalidFields).toEqual([{ path: ["a"], value: "C" }]); 131 | }); 132 | 133 | it("should not report invalid fields if the incoming nested data matches the schema", () => { 134 | const Child = schema.object({ 135 | name: "Child", 136 | fields: { 137 | a: schema.number(), 138 | }, 139 | }); 140 | const Parent = schema.object({ 141 | name: "Parent", 142 | fields: { child: Child }, 143 | }); 144 | const cache = new Cache({ types: [Parent] }); 145 | const { invalidFields } = cache.write({ 146 | type: "Parent", 147 | data: { child: { id: "1", a: 1 } }, 148 | }); 149 | expect(invalidFields).toBeUndefined(); 150 | }); 151 | 152 | it("should report invalid fields if the incoming nested data does not match the schema", () => { 153 | const Child = schema.object({ 154 | name: "Child", 155 | fields: { 156 | a: schema.number(), 157 | }, 158 | }); 159 | const Parent = schema.object({ 160 | name: "Parent", 161 | fields: { child: Child }, 162 | }); 163 | const cache = new Cache({ types: [Parent] }); 164 | const { invalidFields } = cache.write({ 165 | type: "Parent", 166 | data: { child: { id: "1", a: "a" } }, 167 | }); 168 | expect(invalidFields).toEqual([{ path: ["child", "a"], value: "a" }]); 169 | }); 170 | 171 | it("should not report invalid fields if the incoming data is missing optional data", () => { 172 | const Child = schema.object({ 173 | name: "Child", 174 | fields: { 175 | a: schema.number(), 176 | }, 177 | }); 178 | const Parent = schema.object({ 179 | name: "Parent", 180 | fields: { child: Child }, 181 | }); 182 | const cache = new Cache({ types: [Parent] }); 183 | const { invalidFields } = cache.write({ 184 | type: "Parent", 185 | data: { child: { id: "1" } }, 186 | }); 187 | expect(invalidFields).toBeUndefined(); 188 | }); 189 | }); 190 | 191 | describe("reading", () => { 192 | const Child = schema.object({ 193 | name: "Child", 194 | fields: { 195 | id: schema.string(), 196 | a: schema.string(), 197 | b: schema.nonNullable(schema.number()), 198 | c: schema.nonNullable(schema.number()), 199 | d: schema.number(), 200 | e: schema.array(schema.number()), 201 | f: { 202 | type: schema.string(), 203 | read: () => 1, 204 | }, 205 | g: schema.object(), 206 | }, 207 | }); 208 | const Parent = schema.object({ 209 | name: "Parent", 210 | fields: { child: Child }, 211 | }); 212 | const Primitive = schema.string({ 213 | name: "Primitive", 214 | }); 215 | 216 | it("should not report missing fields if the selected data is found", () => { 217 | const cache = new Cache({ types: [Parent] }); 218 | cache.write({ 219 | type: "Parent", 220 | data: { child: { id: "1", a: "a" } }, 221 | }); 222 | const result = cache.read({ 223 | type: "Parent", 224 | select: cql`{ child { a } }`, 225 | }); 226 | expect(result!.data).toEqual({ child: { a: "a" } }); 227 | expect(result!.missingFields).toBeUndefined(); 228 | }); 229 | 230 | it("should not report missing fields if the selected data is in the cache but set to undefined", () => { 231 | const cache = new Cache({ types: [Parent] }); 232 | cache.write({ 233 | type: "Parent", 234 | data: { child: { id: "1", c: undefined } }, 235 | }); 236 | const result = cache.read({ 237 | type: "Parent", 238 | select: cql`{ child { c } }`, 239 | }); 240 | expect(result!.data).toEqual({ child: {} }); 241 | expect(result!.missingFields).toBeUndefined(); 242 | }); 243 | 244 | it("should report missing fields if the selected data is missing", () => { 245 | const cache = new Cache({ types: [Parent] }); 246 | cache.write({ 247 | type: "Parent", 248 | data: { child: { id: "1" } }, 249 | }); 250 | const result = cache.read({ 251 | type: "Parent", 252 | select: cql`{ child { b } }`, 253 | }); 254 | expect(result!.data).toEqual({ child: {} }); 255 | expect(result!.missingFields).toEqual([{ path: ["child", "b"] }]); 256 | }); 257 | 258 | it("should report invalid fields if the entity is invalid", () => { 259 | const cache = new Cache({ types: [Primitive] }); 260 | cache.write({ type: "Primitive", data: 1 }); 261 | const result = cache.read({ type: "Primitive" }); 262 | expect(result!.data).toEqual(1); 263 | expect(result!.invalidFields).toEqual([{ path: [], value: 1 }]); 264 | }); 265 | 266 | it("should report invalid fields if the computed field is invalid", () => { 267 | const cache = new Cache({ types: [Parent] }); 268 | cache.write({ 269 | type: "Parent", 270 | data: { child: { id: "1" } }, 271 | }); 272 | const result = cache.read({ 273 | type: "Parent", 274 | select: cql`{ child { f } }`, 275 | }); 276 | expect(result!.data).toEqual({ child: { f: 1 } }); 277 | expect(result!.invalidFields).toEqual([ 278 | { path: ["child", "f"], value: 1 }, 279 | ]); 280 | }); 281 | 282 | it("should not report invalid fields if the selected data is found and valid", () => { 283 | const cache = new Cache({ types: [Parent] }); 284 | cache.write({ 285 | type: "Parent", 286 | data: { child: { id: "1", a: "a" } }, 287 | }); 288 | const result = cache.read({ 289 | type: "Parent", 290 | select: cql`{ child { a } }`, 291 | }); 292 | expect(result!.data).toEqual({ child: { a: "a" } }); 293 | expect(result!.invalidFields).toBeUndefined(); 294 | }); 295 | 296 | it("should report invalid fields if the selected data is in the cache but invalid", () => { 297 | const cache = new Cache({ types: [Parent] }); 298 | cache.write({ 299 | type: "Parent", 300 | data: { child: { id: "1", c: undefined } }, 301 | }); 302 | const result = cache.read({ 303 | type: "Parent", 304 | select: cql`{ child { c } }`, 305 | }); 306 | expect(result!.data).toEqual({ child: { c: undefined } }); 307 | expect(result!.invalidFields).toEqual([ 308 | { path: ["child", "c"], value: undefined }, 309 | ]); 310 | }); 311 | 312 | it("should report invalid fields if an array contains invalid data", () => { 313 | const cache = new Cache({ types: [Parent] }); 314 | cache.write({ 315 | type: "Parent", 316 | data: { child: { id: "1", e: ["invalid"] } }, 317 | }); 318 | const result = cache.read({ 319 | type: "Parent", 320 | select: cql`{ child { e } }`, 321 | }); 322 | expect(result!.data).toEqual({ child: { e: ["invalid"] } }); 323 | expect(result!.invalidFields).toEqual([ 324 | { path: ["child", "e", 0], value: "invalid" }, 325 | ]); 326 | }); 327 | 328 | it("should read fields from objects without fields defined when onlyReadKnownFields is enabled", () => { 329 | const cache = new Cache({ types: [Parent], onlyReadKnownFields: true }); 330 | cache.write({ 331 | type: "Parent", 332 | data: { child: { id: "1", g: { a: "a" } } }, 333 | }); 334 | const readResult = cache.read({ 335 | type: "Parent", 336 | select: cql`{ child { g { a } } }`, 337 | }); 338 | expect(readResult!.data).toEqual({ child: { g: { a: "a" } } }); 339 | }); 340 | 341 | it("should not read fields unknown to the schema if onlyReadKnownFields is enabled", () => { 342 | const cache = new Cache({ types: [Parent], onlyReadKnownFields: true }); 343 | cache.write({ 344 | type: "Parent", 345 | data: { child: { id: "1", a: "a", z: "z" } }, 346 | }); 347 | const readResult = cache.read({ 348 | type: "Parent", 349 | select: cql`{ child { a z } }`, 350 | }); 351 | expect(readResult!.data).toEqual({ child: { a: "a" } }); 352 | }); 353 | }); 354 | }); 355 | -------------------------------------------------------------------------------- /src/__tests__/read.spec.ts: -------------------------------------------------------------------------------- 1 | import { Cache, cql, schema } from ".."; 2 | 3 | describe("Read", () => { 4 | it("should be able to read a primtive", () => { 5 | const Type = schema.string({ name: "Type" }); 6 | const cache = new Cache({ types: [Type] }); 7 | cache.write({ type: "Type", data: "a" }); 8 | const result = cache.read({ type: "Type" }); 9 | expect(result!.data).toBe("a"); 10 | }); 11 | 12 | it("should be able to read a single field with a selector", () => { 13 | const Type = schema.object({ name: "Type" }); 14 | const cache = new Cache({ types: [Type] }); 15 | cache.write({ type: "Type", data: { a: "a", b: "b" } }); 16 | const result = cache.read({ type: "Type", select: cql`{ a }` }); 17 | expect(result!.data).toEqual({ a: "a" }); 18 | }); 19 | 20 | it("should be able to read multiple fields with a selector", () => { 21 | const Type = schema.object({ name: "Type" }); 22 | const cache = new Cache({ types: [Type] }); 23 | cache.write({ type: "Type", data: { a: "a", b: "b" } }); 24 | const result = cache.read({ type: "Type", select: cql`{ a b }` }); 25 | expect(result!.data).toEqual({ a: "a", b: "b" }); 26 | }); 27 | 28 | it("should be able to read fields with spaces with a selector", () => { 29 | const Type = schema.object({ name: "Type" }); 30 | const cache = new Cache({ types: [Type] }); 31 | cache.write({ type: "Type", data: { "a a": "a", b: "b" } }); 32 | const result = cache.read({ type: "Type", select: cql`{ "a a" b }` }); 33 | expect(result!.data).toEqual({ "a a": "a", b: "b" }); 34 | }); 35 | 36 | it("should be able to read all fields with the star selector", () => { 37 | const Type = schema.object({ name: "Type" }); 38 | const cache = new Cache({ types: [Type] }); 39 | cache.write({ type: "Type", data: { a: "a", b: "b" } }); 40 | const result = cache.read({ type: "Type", select: cql`{ * }` }); 41 | expect(result!.data).toEqual({ a: "a", b: "b" }); 42 | }); 43 | 44 | it("should be able to read a specific nested field with a selector", () => { 45 | const Type = schema.object({ name: "Type" }); 46 | const cache = new Cache({ types: [Type] }); 47 | cache.write({ type: "Type", data: { a: { aa: "aa", bb: "bb" } } }); 48 | const result = cache.read({ 49 | type: "Type", 50 | select: cql`{ a { aa } }`, 51 | }); 52 | expect(result!.data).toEqual({ a: { aa: "aa" } }); 53 | }); 54 | 55 | it("should be able to read all nested fields with a selector", () => { 56 | const Type = schema.object({ name: "Type" }); 57 | const cache = new Cache({ types: [Type] }); 58 | cache.write({ type: "Type", data: { a: { aa: "aa", bb: "bb" } } }); 59 | const result = cache.read({ 60 | type: "Type", 61 | select: cql`{ a { aa bb } }`, 62 | }); 63 | expect(result!.data).toEqual({ a: { aa: "aa", bb: "bb" } }); 64 | }); 65 | 66 | it("should return an empty object if all nested fields are missing", () => { 67 | const Type = schema.object({ name: "Type" }); 68 | const cache = new Cache({ types: [Type] }); 69 | cache.write({ type: "Type", data: { a: { aa: "aa" } } }); 70 | const result = cache.read({ 71 | type: "Type", 72 | select: cql`{ a { bb } }`, 73 | }); 74 | expect(result!.data).toEqual({ a: {} }); 75 | }); 76 | 77 | it("should be able to read nested arrays", () => { 78 | const Type = schema.object({ name: "Type", fields: { a: schema.array() } }); 79 | const cache = new Cache({ types: [Type] }); 80 | cache.write({ type: "Type", data: { a: [1, 2] } }); 81 | const result = cache.read({ type: "Type", select: cql`{ a }` }); 82 | expect(result!.data).toEqual({ a: [1, 2] }); 83 | }); 84 | 85 | it("should be able to read a specific field within arrays", () => { 86 | const Type = schema.array({ name: "Type" }); 87 | const cache = new Cache({ types: [Type] }); 88 | cache.write({ type: "Type", data: [{ a: "a", b: "b" }] }); 89 | const result = cache.read({ type: "Type", select: cql`{ a }` }); 90 | expect(result!.data).toEqual([{ a: "a" }]); 91 | }); 92 | 93 | it("should be able to alias a field with a selector", () => { 94 | const Type = schema.object({ name: "Type" }); 95 | const cache = new Cache({ types: [Type] }); 96 | cache.write({ type: "Type", data: { a: "a" } }); 97 | const result = cache.read({ 98 | type: "Type", 99 | select: cql`{ c: a }`, 100 | }); 101 | expect(result!.data).toEqual({ c: "a" }); 102 | }); 103 | 104 | it("should be able to read with a fragment", () => { 105 | const Type = schema.object({ name: "Type" }); 106 | const cache = new Cache({ types: [Type] }); 107 | cache.write({ type: "Type", data: { id: "1" } }); 108 | const result = cache.read({ 109 | type: "Type", 110 | id: "1", 111 | select: cql`fragment TypeDetail on Type { id }`, 112 | }); 113 | expect(result!.data).toEqual({ id: "1" }); 114 | }); 115 | 116 | it("should throw if the fragment type does not match", () => { 117 | const Type = schema.object({ name: "Type" }); 118 | const cache = new Cache({ types: [Type] }); 119 | cache.write({ type: "Type", data: { id: "1" } }); 120 | expect(() => { 121 | cache.read({ 122 | type: "Type", 123 | id: "1", 124 | select: cql`fragment TypeDetail on DifferentType { id }`, 125 | }); 126 | }).toThrow(); 127 | }); 128 | 129 | it("should be able to read with an inline fragment", () => { 130 | const Type = schema.object({ name: "Type" }); 131 | const cache = new Cache({ types: [Type] }); 132 | cache.write({ type: "Type", data: { a: "a", b: "b" } }); 133 | const result = cache.read({ 134 | type: "Type", 135 | select: cql`{ ... on Type { b } }`, 136 | }); 137 | expect(result!.data).toEqual({ b: "b" }); 138 | }); 139 | 140 | it("should be able to read with multiple inline fragments", () => { 141 | const Type = schema.object({ name: "Type" }); 142 | const cache = new Cache({ types: [Type] }); 143 | cache.write({ type: "Type", data: { a: "a", b: "b" } }); 144 | const result = cache.read({ 145 | type: "Type", 146 | select: cql`{ ... on Type { b } ... on Type { a } }`, 147 | }); 148 | expect(result!.data).toEqual({ a: "a", b: "b" }); 149 | }); 150 | 151 | it("should return an empty object if none of the inline fragments match", () => { 152 | const Type = schema.object({ name: "Type" }); 153 | const cache = new Cache({ types: [Type] }); 154 | cache.write({ type: "Type", data: { a: "a", b: "b" } }); 155 | const result = cache.read({ 156 | type: "Type", 157 | select: cql`{ ... on DifferentType { b } }`, 158 | }); 159 | expect(result!.data).toEqual({}); 160 | }); 161 | 162 | it("should be able to spread fragments", () => { 163 | const ChildFrag = cql`fragment ChildFrag on Child { b }`; 164 | const Child = schema.object({ name: "Child" }); 165 | const Parent = schema.object({ name: "Parent", fields: { child: Child } }); 166 | const cache = new Cache({ types: [Parent] }); 167 | cache.write({ type: "Parent", data: { child: { a: "a", b: "b" } } }); 168 | const result = cache.read({ 169 | type: "Parent", 170 | select: cql`{ child { ...ChildFrag } } ${ChildFrag}`, 171 | }); 172 | expect(result!.data).toEqual({ child: { b: "b" } }); 173 | }); 174 | 175 | it("should be able to read specific fields from unions", () => { 176 | const Type1 = schema.object({ 177 | name: "Type1", 178 | isOfType: (value: any) => value.type === "1", 179 | }); 180 | const Type2 = schema.object({ 181 | name: "Type2", 182 | isOfType: (value: any) => value.type === "2", 183 | }); 184 | const Search = schema.array({ 185 | name: "Search", 186 | ofType: schema.union({ types: [Type1, Type2] }), 187 | }); 188 | const cache = new Cache({ types: [Search] }); 189 | cache.write({ 190 | type: "Search", 191 | data: [ 192 | { type: "1", a: "a", b: "b" }, 193 | { type: "2", a: "a", b: "b" }, 194 | ], 195 | }); 196 | const result = cache.read({ 197 | type: "Search", 198 | select: cql`{ ... on Type1 { a } ... on Type2 { b } }`, 199 | }); 200 | expect(result!.data).toEqual([{ a: "a" }, { b: "b" }]); 201 | }); 202 | 203 | it("should not be able to modify cache values", () => { 204 | const Child = schema.object({ name: "Child" }); 205 | const Parent = schema.object({ name: "Parent", fields: { child: Child } }); 206 | const cache = new Cache({ types: [Parent] }); 207 | cache.write({ 208 | type: "Parent", 209 | data: { a: "a", child: { id: "1", a: "a", b: { c: "c" } } }, 210 | }); 211 | const result = cache.read({ type: "Parent" }); 212 | result!.data.child.b.c = "d"; 213 | const result2 = cache.read({ 214 | type: "Parent", 215 | select: cql`{ child { b { c } } }`, 216 | }); 217 | expect(result2!.data).toEqual({ 218 | child: { b: { c: "c" } }, 219 | }); 220 | }); 221 | 222 | it("should be able to read specifc fields from nested entities", () => { 223 | const Child = schema.object({ name: "Child" }); 224 | const Parent = schema.object({ name: "Parent", fields: { child: Child } }); 225 | const cache = new Cache({ types: [Parent] }); 226 | cache.write({ type: "Parent", data: { child: { id: "1", a: "a" } } }); 227 | const result = cache.read({ 228 | type: "Parent", 229 | select: cql`{ child { a } }`, 230 | }); 231 | expect(result!.data).toEqual({ child: { a: "a" } }); 232 | }); 233 | 234 | it("should be able to read all fields from nested entities by only specifying the entity field", () => { 235 | const Child = schema.object({ name: "Child" }); 236 | const Parent = schema.object({ name: "Parent", fields: { child: Child } }); 237 | const cache = new Cache({ types: [Parent] }); 238 | cache.write({ 239 | type: "Parent", 240 | data: { child: { id: "1", a: "a", b: { c: "c" } } }, 241 | }); 242 | const result = cache.read({ 243 | type: "Parent", 244 | select: cql`{ child }`, 245 | }); 246 | expect(result!.data).toEqual({ child: { id: "1", a: "a", b: { c: "c" } } }); 247 | }); 248 | 249 | it("should be able to read all fields from nested entities with circular references by only specifying the entity field", () => { 250 | const Child = schema.object({ 251 | name: "Child", 252 | fields: () => ({ parent: Parent }), 253 | }); 254 | const Parent = schema.object({ name: "Parent", fields: { child: Child } }); 255 | const cache = new Cache({ types: [Parent] }); 256 | cache.write({ 257 | type: "Parent", 258 | data: { id: "1", child: { id: "2", a: "a", parent: { id: "1" } } }, 259 | }); 260 | const result = cache.read({ 261 | type: "Parent", 262 | id: "1", 263 | select: cql`{ child }`, 264 | }); 265 | expect(result!.data.id).toBe(undefined); 266 | expect(result!.data.child.id).toBe("2"); 267 | expect(result!.data.child.a).toBe("a"); 268 | expect(result!.data.child.parent.id).toBe("1"); 269 | expect(result!.data.child.parent.child.id).toBe("2"); 270 | expect(result!.data.child.parent.child.parent.child.a).toBe("a"); 271 | }); 272 | 273 | it("should be able to read all fields from nested entities with circular references by only specifying the entity", () => { 274 | const Child = schema.object({ 275 | name: "Child", 276 | fields: () => ({ parent: Parent }), 277 | }); 278 | const Parent = schema.object({ name: "Parent", fields: { child: Child } }); 279 | const cache = new Cache({ types: [Parent] }); 280 | cache.write({ 281 | type: "Parent", 282 | data: { id: "1", child: { id: "2", a: "a", parent: { id: "1" } } }, 283 | }); 284 | const result = cache.read({ type: "Parent", id: "1" }); 285 | expect(result!.data.id).toBe("1"); 286 | expect(result!.data.child.id).toBe("2"); 287 | expect(result!.data.child.a).toBe("a"); 288 | expect(result!.data.child.parent.id).toBe("1"); 289 | expect(result!.data.child.parent.child.id).toBe("2"); 290 | expect(result!.data.child.parent.child.parent.child.a).toBe("a"); 291 | }); 292 | 293 | it("should be able to read all fields with a star but have specific selection sets for some fields", () => { 294 | const Child = schema.object({ name: "Child" }); 295 | const Parent = schema.object({ name: "Parent", fields: { child: Child } }); 296 | const cache = new Cache({ types: [Parent] }); 297 | cache.write({ 298 | type: "Parent", 299 | data: { a: "a", child: { id: "1", a: "a", b: { c: "c" } } }, 300 | }); 301 | const result = cache.read({ 302 | type: "Parent", 303 | select: cql`{ * child { b } }`, 304 | }); 305 | expect(result!.data).toEqual({ a: "a", child: { b: { c: "c" } } }); 306 | }); 307 | 308 | it("should be able to define a computed field", () => { 309 | const Type = schema.object({ 310 | name: "Type", 311 | fields: { 312 | computed: { 313 | read: (parent) => parent.a, 314 | }, 315 | }, 316 | }); 317 | const cache = new Cache({ types: [Type] }); 318 | cache.write({ type: "Type", data: { a: "a" } }); 319 | const result = cache.read({ type: "Type", select: cql`{ a computed }` }); 320 | expect(result!.data).toEqual({ a: "a", computed: "a" }); 321 | }); 322 | 323 | it("should be able to return references in computed fields", () => { 324 | const Author = schema.object({ name: "Author" }); 325 | const Post = schema.object({ 326 | name: "Post", 327 | fields: { 328 | author: { 329 | read: (post, ctx) => { 330 | return ctx.toReference({ type: "Author", id: post.authorId }); 331 | }, 332 | }, 333 | }, 334 | }); 335 | const cache = new Cache({ types: [Post, Author] }); 336 | cache.write({ type: "Post", data: { id: "1", authorId: "2" } }); 337 | cache.write({ type: "Author", data: { id: "2", name: "Name" } }); 338 | const result = cache.read({ 339 | type: "Post", 340 | id: "1", 341 | select: cql`{ author { name } }`, 342 | }); 343 | expect(result!.data).toEqual({ author: { name: "Name" } }); 344 | }); 345 | }); 346 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Normalized Cache 2 | 3 | This normalized cache provides the following functionality: 4 | 5 | - Data (de)normalization 6 | - Data subscriptions 7 | - Data validation 8 | - Data invalidation 9 | - Data expiration 10 | - Computed fields 11 | - Optimistic updates 12 | - Garbage collection 13 | 14 | The library is around 6 KB gzipped when using all features. 15 | 16 | ## Setup 17 | 18 | Installation: 19 | 20 | ```sh 21 | npm install --save normalized-cache 22 | ``` 23 | 24 | ## Usage 25 | 26 | ```js 27 | import { Cache, schema } from "normalized-cache"; 28 | 29 | const Author = schema.object({ 30 | name: "Author", 31 | }); 32 | 33 | const Post = schema.object({ 34 | name: "Post", 35 | fields: { 36 | author: Author, 37 | }, 38 | }); 39 | 40 | const cache = new Cache({ 41 | types: [Post], 42 | }); 43 | 44 | cache.write({ 45 | type: "Post", 46 | data: { 47 | id: "1", 48 | title: "Title", 49 | author: { 50 | id: "2", 51 | name: "Name", 52 | }, 53 | }, 54 | }); 55 | 56 | const result = cache.read({ 57 | type: "Post", 58 | id: "1", 59 | }); 60 | 61 | console.log(result.data); 62 | 63 | // { 64 | // id: "1", 65 | // title: "Title", 66 | // author: { 67 | // id: "2", 68 | // name: "Name", 69 | // }, 70 | // } 71 | 72 | const result2 = cache.read({ 73 | type: "Author", 74 | id: "2", 75 | }); 76 | 77 | console.log(result2.data); 78 | 79 | // { 80 | // id: "2", 81 | // name: "Name", 82 | // } 83 | 84 | const result3 = cache.read({ 85 | type: "Post", 86 | id: "1", 87 | select: cql`{ title author { name } }`, 88 | }); 89 | 90 | console.log(result3.data); 91 | 92 | // { 93 | // title: "Title", 94 | // author: { 95 | // name: "Name", 96 | // }, 97 | // } 98 | ``` 99 | 100 | ## API 101 | 102 | ```ts 103 | class Cache { 104 | constructor(config?: CacheConfig): Cache; 105 | get(entityID: string, optimistic?: boolean): Entity | undefined; 106 | set(entityID: string, entity: Entity | undefined, optimistic?: boolean): Entity; 107 | identify(options: IdentifyOptions): string | undefined; 108 | read(options: ReadOptions): ReadResult | undefined; 109 | write(options: WriteOptions): WriteResult; 110 | delete(options: DeleteOptions): DeleteResult | undefined; 111 | invalidate(options: InvalidateOptions): InvalidateResult | undefined; 112 | watch(options: WatchOptions): Unsubscribable; 113 | silent(fn: () => void): void; 114 | transaction(fn: () => void): void; 115 | extract(optimistic?: boolean): SerializedData; 116 | restore(data: SerializedData): void; 117 | reset(): void; 118 | gc(): void; 119 | retain(entityID: string): Disposable; 120 | addOptimisticUpdate(updateFn: OptimisticUpdateFn): OptimisticUpdateDisposable; 121 | removeOptimisticUpdate(id: number): void; 122 | } 123 | 124 | interface CacheConfig { 125 | types?: ValueType[]; 126 | onlyReadKnownFields?: boolean; 127 | onlyWriteKnownFields?: boolean; 128 | } 129 | 130 | const schema = { 131 | array(config?: ArrayTypeConfig | ValueType): ArrayType 132 | boolean(config?: BooleanTypeConfig): BooleanType 133 | nonNullable(config: NonNullableTypeConfig | ValueType): NonNullableType 134 | number(config?: NumberTypeConfig): NumberType 135 | object(config?: ObjectTypeConfig): ObjectType 136 | string(config?: StringTypeConfig | string): StringType 137 | union(config: UnionTypeConfig | ValueType[]): UnionType 138 | } 139 | ``` 140 | 141 | ## Schema 142 | 143 | Schema types allow you to define entities, relationships and fields. 144 | 145 | Learn more about the type system [here](./docs/Schema.md). 146 | 147 | ## Writing 148 | 149 | When writing to the cache, a type must be provided. 150 | 151 | ```js 152 | cache.write({ 153 | type: "Post", 154 | data: { id: "1", title: "Title" }, 155 | }); 156 | ``` 157 | 158 | A ID can be specified if this cannot be inferred from the data itself: 159 | 160 | ```js 161 | cache.write({ 162 | type: "Post", 163 | id: "1", 164 | data: { title: "Title" }, 165 | }); 166 | ``` 167 | 168 | If the ID is an object or array it will be automatically serialized to a stable string: 169 | 170 | ```js 171 | cache.write({ 172 | type: "Posts", 173 | id: { page: 1, limit: 10 }, 174 | data: [], 175 | }); 176 | ``` 177 | 178 | ## Reading 179 | 180 | Reading from the cache can be done with the `read` method. 181 | 182 | ### Without selector 183 | 184 | When no selector is given, all data related to the entity will be returned: 185 | 186 | ```js 187 | cache.write({ 188 | type: "Author", 189 | data: { 190 | id: "2", 191 | name: "Name", 192 | }, 193 | }); 194 | 195 | cache.write({ 196 | type: "Post", 197 | data: { 198 | id: "1", 199 | title: "Title", 200 | author: { 201 | id: "2", 202 | }, 203 | }, 204 | }); 205 | 206 | const result = cache.read({ 207 | type: "Post", 208 | id: "1", 209 | }); 210 | 211 | console.log(result.data); 212 | 213 | // { 214 | // id: "1", 215 | // title: "Title", 216 | // author: { 217 | // id: "2", 218 | // name: "Name", 219 | // }, 220 | // } 221 | ``` 222 | 223 | The resulting data can contain circular references when entities refer to each other. 224 | 225 | ### With selector 226 | 227 | Selectors can be used to select specific fields: 228 | 229 | ```js 230 | import { cql } from "normalized-cache"; 231 | 232 | cache.write({ 233 | type: "Author", 234 | data: { 235 | id: "2", 236 | name: "Name", 237 | }, 238 | }); 239 | 240 | cache.write({ 241 | type: "Post", 242 | data: { 243 | id: "1", 244 | title: "Title", 245 | author: { 246 | id: "2", 247 | }, 248 | }, 249 | }); 250 | 251 | const result = cache.read({ 252 | type: "Post", 253 | id: "1", 254 | select: cql`{ title author { name } }`, 255 | }); 256 | 257 | console.log(result.data); 258 | 259 | // { 260 | // title: "Title", 261 | // author: { 262 | // name: "Name", 263 | // }, 264 | // } 265 | ``` 266 | 267 | Learn more about selectors [here](./docs/CQL.md). 268 | 269 | ### With write selector 270 | 271 | The `write` method also returns a selector that matches the exact shape of the input: 272 | 273 | ```js 274 | cache.write({ 275 | type: "Author", 276 | data: { 277 | id: "2", 278 | name: "Name", 279 | }, 280 | }); 281 | 282 | const { selector } = cache.write({ 283 | type: "Post", 284 | data: { 285 | id: "1", 286 | title: "Title", 287 | author: { 288 | id: "2", 289 | }, 290 | }, 291 | }); 292 | 293 | const result = cache.read({ 294 | type: "Post", 295 | id: "1", 296 | select: selector, 297 | }); 298 | 299 | console.log(result.data); 300 | 301 | // { 302 | // id: "1", 303 | // title: "Title", 304 | // author: { 305 | // id: "2", 306 | // }, 307 | // } 308 | ``` 309 | 310 | ### Computed fields 311 | 312 | Computed fields can be created by defining a field with a `read` function. 313 | 314 | Defining a computed field for calculations: 315 | 316 | ```js 317 | const Cart = schema.object({ 318 | name: "Cart", 319 | fields: { 320 | totalPrice: { 321 | read: (cart) => { 322 | return cart.items.reduce((total, item) => total + item.price, 0); 323 | }, 324 | }, 325 | }, 326 | }); 327 | ``` 328 | 329 | Defining a relational field based on another field: 330 | 331 | ```js 332 | const Author = schema.object({ 333 | name: "Author", 334 | }); 335 | 336 | const Post = schema.object({ 337 | name: "Post", 338 | fields: { 339 | author: { 340 | read: (post, { toReference }) => { 341 | return toReference({ type: "Author", id: post.authorId }); 342 | }, 343 | }, 344 | }, 345 | }); 346 | ``` 347 | 348 | ### Invalid fields 349 | 350 | Fields that do not match with the schema will be reported in the `invalidFields` array: 351 | 352 | ```js 353 | const LoggedIn = schema.boolean({ 354 | name: "LoggedIn", 355 | }); 356 | 357 | const cache = new Cache({ 358 | types: [LoggedIn], 359 | }); 360 | 361 | cache.write({ 362 | type: "LoggedIn", 363 | data: "string", 364 | }); 365 | 366 | const result = cache.read({ 367 | type: "LoggedIn", 368 | }); 369 | 370 | if (result.invalidFields) { 371 | console.log("Invalid data"); 372 | } 373 | ``` 374 | 375 | ### Missing fields 376 | 377 | Fields that are missing will be reported in the `missingFields` array: 378 | 379 | ```js 380 | const LoggedIn = schema.boolean({ 381 | name: "LoggedIn", 382 | }); 383 | 384 | const cache = new Cache({ 385 | types: [LoggedIn], 386 | }); 387 | 388 | const result = cache.read({ 389 | type: "LoggedIn", 390 | }); 391 | 392 | if (result.missingFields) { 393 | console.log("Missing data"); 394 | } 395 | ``` 396 | 397 | ### Stale flag 398 | 399 | The `stale` flag indicates if some entity or field has been invalidated or if any `expiresAt` has past: 400 | 401 | ```js 402 | const LoggedIn = schema.boolean({ 403 | name: "LoggedIn", 404 | }); 405 | 406 | const cache = new Cache({ 407 | types: [LoggedIn], 408 | }); 409 | 410 | cache.write({ 411 | type: "LoggedIn", 412 | data: true, 413 | expiresAt: 0, 414 | }); 415 | 416 | const result = cache.read({ 417 | type: "LoggedIn", 418 | }); 419 | 420 | if (result.stale) { 421 | console.log("Stale data"); 422 | } 423 | ``` 424 | 425 | ## Watching 426 | 427 | Data in the cache can be watched with the `watch` method. 428 | 429 | Watching for any change in a specific post and all related data: 430 | 431 | ```js 432 | const { unsubscribe } = cache.watch({ 433 | type: "Post", 434 | id: "1", 435 | callback: (result, prevResult) => { 436 | // log 437 | }, 438 | }); 439 | 440 | unsubscribe(); 441 | ``` 442 | 443 | Watching specific fields: 444 | 445 | ```js 446 | cache.watch({ 447 | type: "Post", 448 | id: "1", 449 | select: cql`{ title }`, 450 | callback: (result, prevResult) => { 451 | if (!prevResult.stale && result.stale) { 452 | // The title became stale 453 | } 454 | }, 455 | }); 456 | ``` 457 | 458 | ## Invalidation 459 | 460 | Entities and fields can be invalidated with the `invalidate` method. 461 | 462 | When an entity or field is invalidated, all related watchers will be notified. 463 | 464 | Invalidate an entity: 465 | 466 | ```js 467 | cache.invalidate({ 468 | type: "Post", 469 | id: "1", 470 | }); 471 | ``` 472 | 473 | Invalidate entity fields: 474 | 475 | ```js 476 | cache.invalidate({ 477 | type: "Post", 478 | id: "1", 479 | select: cql`{ comments }`, 480 | }); 481 | ``` 482 | 483 | ## Expiration 484 | 485 | when `expiresAt` is specified, all affected fields will be considered stale after the given time: 486 | 487 | ```js 488 | cache.write({ 489 | type: "Post", 490 | data: { id: "1" }, 491 | expiresAt: Date.now() + 60 * 1000, 492 | }); 493 | ``` 494 | 495 | Set expiration for certain types: 496 | 497 | ```js 498 | cache.write({ 499 | type: "Post", 500 | data: { id: "1" }, 501 | expiresAt: { 502 | Comment: Date.now() + 60 * 1000, 503 | }, 504 | }); 505 | ``` 506 | 507 | ## Deletion 508 | 509 | Entities and fields can be deleted with the `delete` method. 510 | 511 | Deleting an entity: 512 | 513 | ```js 514 | cache.delete({ 515 | type: "Post", 516 | id: "1", 517 | }); 518 | ``` 519 | 520 | Deleting specific fields: 521 | 522 | ```js 523 | cache.delete({ 524 | type: "Post", 525 | id: "1", 526 | select: cql`{ title }`, 527 | }); 528 | ``` 529 | 530 | ## Optimistic updates 531 | 532 | An optimistic update function can be used to update the cache optimistically. 533 | 534 | These functions will be executed everytime the cache is updated, until they are removed. 535 | 536 | This means that if new data is written to the cache, the optimistic update will be re-applied / rebased on top of the new data. 537 | 538 | ```js 539 | async function addComment(postID, text) { 540 | function addCommentToPost(comment) { 541 | const result = cache.read({ 542 | type: "Post", 543 | id: postID, 544 | select: cql`{ comments }`, 545 | }); 546 | 547 | cache.write({ 548 | type: "Post", 549 | id: postID, 550 | data: { comments: [...result.data.comments, comment] }, 551 | }); 552 | } 553 | 554 | const update = cache.addOptimisticUpdate(() => { 555 | addCommentToPost({ id: uuid(), text }); 556 | }); 557 | 558 | const comment = await api.addComment(postID, text); 559 | 560 | cache.transaction(() => { 561 | update.dispose(); 562 | addCommentToPost(comment); 563 | }); 564 | } 565 | ``` 566 | 567 | ## Merging 568 | 569 | By default entities are shallowly merged and non-entity values are replaced. 570 | 571 | This behavior can be customized by defining custom write functions on entities and fields. 572 | 573 | Replacing entities instead of merging: 574 | 575 | ```js 576 | const Author = schema.object({ 577 | name: "Author", 578 | write: (incoming) => { 579 | return incoming; 580 | }, 581 | }); 582 | ``` 583 | 584 | Merging objects instead of replacing: 585 | 586 | ```js 587 | const Post = schema.object({ 588 | name: "Post", 589 | fields: { 590 | content: { 591 | type: schema.object(), 592 | write: (incoming, existing) => { 593 | return { ...existing, ...incoming }; 594 | }, 595 | }, 596 | }, 597 | }); 598 | ``` 599 | 600 | Transforming values when writing: 601 | 602 | ```js 603 | const Post = schema.object({ 604 | name: "Post", 605 | fields: { 606 | title: { 607 | write: (incoming) => { 608 | if (typeof incoming === "string") { 609 | return incoming.toUpperCase(); 610 | } 611 | }, 612 | }, 613 | }, 614 | }); 615 | ``` 616 | 617 | ## Transactions 618 | 619 | Multiple changes can be wrapped in a transaction to make sure watchers are only notified once after the last change: 620 | 621 | ```js 622 | cache.transaction(() => { 623 | cache.write({ 624 | type: "Post", 625 | data: { id: "1", title: "1" }, 626 | }); 627 | cache.write({ 628 | type: "Post", 629 | data: { id: "2", title: "2" }, 630 | }); 631 | }); 632 | ``` 633 | 634 | ## Silent changes 635 | 636 | Wrap changes with `silent` to prevent watchers from being notified: 637 | 638 | ```js 639 | cache.silent(() => { 640 | cache.write({ 641 | type: "Post", 642 | data: { id: "1", title: "1" }, 643 | }); 644 | }); 645 | ``` 646 | 647 | ## Garbage collection 648 | 649 | The `gc` method can be used to remove all unwatched and unreachable entities from the cache. 650 | 651 | Use the `retain` method to prevent an entity from being removed. 652 | -------------------------------------------------------------------------------- /src/Cache.ts: -------------------------------------------------------------------------------- 1 | import type { ValueType } from "./schema/types"; 2 | import { getReferencedTypes } from "./schema/utils"; 3 | import { executeWrite, WriteResult } from "./operations/write"; 4 | import { executeRead, ReadResult } from "./operations/read"; 5 | import { 6 | createRecord, 7 | replaceEqualDeep, 8 | hasOwn, 9 | isObject, 10 | clone, 11 | } from "./utils/data"; 12 | import { ErrorCode, invariant } from "./utils/invariant"; 13 | import type { EntitiesRecord, Entity } from "./types"; 14 | import type { DocumentNode } from "./language/ast"; 15 | import { DeleteResult, executeDelete } from "./operations/delete"; 16 | import { executeInvalidate, InvalidateResult } from "./operations/invalidate"; 17 | import { serializeSelector } from "./language/serializer"; 18 | import { 19 | identify, 20 | identifyByData, 21 | identifyById, 22 | isMetaKey, 23 | isReference, 24 | } from "./utils/cache"; 25 | import { Disposable } from "./utils/Disposable"; 26 | import { Unsubscribable } from "./utils/Unsubscribable"; 27 | 28 | export interface ReadOptions { 29 | id?: unknown; 30 | optimistic?: boolean; 31 | select?: DocumentNode; 32 | type: string; 33 | } 34 | 35 | export interface WriteOptions { 36 | data: unknown; 37 | expiresAt?: number; 38 | id?: unknown; 39 | optimistic?: boolean; 40 | type: string; 41 | } 42 | 43 | export interface DeleteOptions { 44 | id?: unknown; 45 | optimistic?: boolean; 46 | select?: DocumentNode; 47 | type: string; 48 | } 49 | 50 | export interface InvalidateOptions { 51 | id?: unknown; 52 | optimistic?: boolean; 53 | select?: DocumentNode; 54 | type: string; 55 | } 56 | 57 | export interface IdentifyOptions { 58 | data?: unknown; 59 | id?: unknown; 60 | type: string; 61 | } 62 | 63 | export interface WatchOptions extends ReadOptions { 64 | callback: ( 65 | result: ReadResult | undefined, 66 | prevResult?: ReadResult 67 | ) => void; 68 | } 69 | 70 | interface Watch { 71 | entityID: string; 72 | options: WatchOptions; 73 | prevResult?: ReadResult; 74 | } 75 | 76 | interface CachedReadResult { 77 | result: ReadResult | undefined; 78 | invalidated?: boolean; 79 | } 80 | 81 | type OptimisticUpdateFn = (cache: Cache) => void; 82 | 83 | class OptimisticUpdateDisposable extends Disposable { 84 | id: number; 85 | 86 | constructor(id: number, disposeFn: () => void) { 87 | super(disposeFn); 88 | this.id = id; 89 | } 90 | } 91 | 92 | interface OptimisticUpdate { 93 | id: number; 94 | updateFn: OptimisticUpdateFn; 95 | } 96 | 97 | interface SerializedData { 98 | entities: EntitiesRecord; 99 | optimisticEntities?: EntitiesRecord; 100 | } 101 | 102 | export interface CacheConfig { 103 | /** 104 | * The schema types. All referenced types will be automatically added. 105 | */ 106 | types?: ValueType[]; 107 | /** 108 | * If enabled, only fields known to the schema will be returned from the cache. 109 | */ 110 | onlyReadKnownFields?: boolean; 111 | /** 112 | * If enabled, only fields known to the schema will be written to the cache. 113 | */ 114 | onlyWriteKnownFields?: boolean; 115 | } 116 | 117 | export class Cache { 118 | _config: CacheConfig; 119 | _entities: EntitiesRecord; 120 | _entitiesRefCount: Record; 121 | _optimisticEntities: EntitiesRecord; 122 | _optimisticReadMode: boolean; 123 | _optimisticWriteMode: boolean; 124 | _optimisticUpdates: OptimisticUpdate[]; 125 | _optimisticUpdateID: number; 126 | _types: Record; 127 | _readResults: Record; 128 | _watches: Watch[]; 129 | _transactions: number; 130 | _transactionHasEntityUpdate: boolean; 131 | _transactionHasOptimisticEntityUpdate: boolean; 132 | _silent: boolean; 133 | 134 | constructor(config: CacheConfig = {}) { 135 | this._config = config; 136 | this._entities = createRecord(); 137 | this._entitiesRefCount = createRecord(); 138 | this._optimisticEntities = createRecord(); 139 | this._optimisticReadMode = true; 140 | this._optimisticWriteMode = false; 141 | this._optimisticUpdates = []; 142 | this._optimisticUpdateID = 0; 143 | this._watches = []; 144 | this._readResults = createRecord(); 145 | this._types = config.types ? getReferencedTypes(config.types) : {}; 146 | this._transactions = 0; 147 | this._transactionHasEntityUpdate = false; 148 | this._transactionHasOptimisticEntityUpdate = false; 149 | this._silent = false; 150 | } 151 | 152 | /** 153 | * Use this method to put the cache enable or disable the optimistc write mode. 154 | * All writes/deletes will be optimistic by default when the optimistic mode is enabled. 155 | */ 156 | setOptimisticWriteMode(value: boolean): void { 157 | this._optimisticWriteMode = value; 158 | } 159 | 160 | identify(options: IdentifyOptions): string | undefined { 161 | const type = ensureType(this, options.type); 162 | if (options.id !== undefined) { 163 | return identifyById(type, options.id); 164 | } else if (options.data !== undefined) { 165 | return identifyByData(type, options.data); 166 | } 167 | } 168 | 169 | get(entityID: string, optimistic?: boolean): Entity | undefined { 170 | return shouldReadOptimistic(this, optimistic) && 171 | hasOwn(this._optimisticEntities, entityID) 172 | ? this._optimisticEntities[entityID] 173 | : this._entities[entityID]; 174 | } 175 | 176 | set( 177 | entityID: string, 178 | entity: Entity | undefined, 179 | optimistic?: boolean 180 | ): Entity | undefined { 181 | const existingEntity = this.get(entityID, optimistic); 182 | const updatedEntity = replaceEqualDeep(existingEntity, entity); 183 | 184 | if (optimistic) { 185 | this._optimisticEntities[entityID] = updatedEntity; 186 | } else { 187 | this._entities[entityID] = updatedEntity; 188 | } 189 | 190 | if (updatedEntity !== existingEntity) { 191 | if (optimistic) { 192 | handleOptimisticEntityUpdate(this); 193 | } else { 194 | handleEntityUpdate(this); 195 | } 196 | } 197 | 198 | return updatedEntity; 199 | } 200 | 201 | read(options: ReadOptions): ReadResult | undefined { 202 | const type = ensureType(this, options.type); 203 | const optimistic = shouldReadOptimistic(this, options.optimistic); 204 | const resultID = getResultID(type, options.select, options.id); 205 | const cachedResult = this._readResults[resultID]; 206 | 207 | if (optimistic && cachedResult && !cachedResult.invalidated) { 208 | return cachedResult.result; 209 | } 210 | 211 | let result = executeRead(this, type, optimistic, { 212 | ...options, 213 | onlyReadKnownFields: this._config.onlyReadKnownFields, 214 | }); 215 | 216 | // Only optimistic results are cached as non-optimistic reads should not occur often 217 | if (optimistic) { 218 | if (cachedResult) { 219 | result = replaceEqualDeep(cachedResult.result, result); 220 | } 221 | this._readResults[resultID] = { result }; 222 | } 223 | 224 | return result; 225 | } 226 | 227 | write(options: WriteOptions): WriteResult { 228 | const type = ensureType(this, options.type); 229 | const optimistic = shouldWriteOptimistic(this, options.optimistic); 230 | return executeWrite(this, type, optimistic, { 231 | ...options, 232 | onlyWriteKnownFields: this._config.onlyWriteKnownFields, 233 | }); 234 | } 235 | 236 | delete(options: DeleteOptions): DeleteResult | undefined { 237 | const type = ensureType(this, options.type); 238 | const optimistic = shouldWriteOptimistic(this, options.optimistic); 239 | return executeDelete(this, type, optimistic, options); 240 | } 241 | 242 | invalidate(options: InvalidateOptions): InvalidateResult | undefined { 243 | const type = ensureType(this, options.type); 244 | const optimistic = shouldWriteOptimistic(this, options.optimistic); 245 | return executeInvalidate(this, type, optimistic, options); 246 | } 247 | 248 | watch(options: WatchOptions): Unsubscribable { 249 | const type = ensureType(this, options.type); 250 | const entityID = identify(type, options.id)!; 251 | const prevResult = this.read(options); 252 | const watch: Watch = { entityID, options, prevResult }; 253 | this._watches.push(watch); 254 | const retainDisposable = this.retain(entityID); 255 | return new Unsubscribable(() => { 256 | retainDisposable.dispose(); 257 | this._watches = this._watches.filter((x) => x !== watch); 258 | }); 259 | } 260 | 261 | addOptimisticUpdate( 262 | updateFn: OptimisticUpdateFn 263 | ): OptimisticUpdateDisposable { 264 | const id = this._optimisticUpdateID++; 265 | this._optimisticUpdates.push({ id, updateFn }); 266 | handleOptimisticUpdatesChange(this); 267 | return new OptimisticUpdateDisposable(id, () => { 268 | this.removeOptimisticUpdate(id); 269 | }); 270 | } 271 | 272 | removeOptimisticUpdate(id: number): void { 273 | this._optimisticUpdates = this._optimisticUpdates.filter( 274 | (x) => x.id !== id 275 | ); 276 | handleOptimisticUpdatesChange(this); 277 | } 278 | 279 | removeOptimisticUpdates(): void { 280 | this._optimisticUpdates = []; 281 | handleOptimisticUpdatesChange(this); 282 | } 283 | 284 | transaction(fn: () => void): void { 285 | this._transactions++; 286 | fn(); 287 | this._transactions--; 288 | 289 | if (!this._transactions) { 290 | const hasUpdates = this._transactionHasEntityUpdate; 291 | const hasOptimisticUpdates = this._transactionHasOptimisticEntityUpdate; 292 | 293 | this._transactionHasEntityUpdate = false; 294 | this._transactionHasOptimisticEntityUpdate = false; 295 | 296 | if (hasUpdates) { 297 | handleEntityUpdate(this); 298 | } else if (hasOptimisticUpdates) { 299 | handleOptimisticEntityUpdate(this); 300 | } 301 | } 302 | } 303 | 304 | silent(fn: () => void): void { 305 | this._silent = true; 306 | fn(); 307 | this._silent = false; 308 | } 309 | 310 | reset(): void { 311 | this._entities = createRecord(); 312 | this._entitiesRefCount = createRecord(); 313 | this._optimisticEntities = createRecord(); 314 | this._optimisticUpdates = []; 315 | this._readResults = createRecord(); 316 | } 317 | 318 | extract(optimistic?: boolean): SerializedData { 319 | const state: SerializedData = { 320 | entities: clone(this._entities), 321 | }; 322 | 323 | if (optimistic) { 324 | state.optimisticEntities = clone(this._optimisticEntities); 325 | } 326 | 327 | return state; 328 | } 329 | 330 | restore(data: SerializedData): void { 331 | this.reset(); 332 | this._entities = data.entities; 333 | 334 | if (data.optimisticEntities) { 335 | this._optimisticEntities = data.optimisticEntities; 336 | } 337 | } 338 | 339 | retain(entityID: string): Disposable { 340 | if (!this._entitiesRefCount[entityID]) { 341 | this._entitiesRefCount[entityID] = 0; 342 | } 343 | 344 | this._entitiesRefCount[entityID]++; 345 | 346 | return new Disposable(() => { 347 | if (this._entitiesRefCount[entityID] > 0) { 348 | this._entitiesRefCount[entityID]--; 349 | } 350 | }); 351 | } 352 | 353 | gc(): void { 354 | const referencedEntities = createRecord(); 355 | 356 | // Find all entities referenced by retained entities 357 | for (const entityID of Object.keys(this._entitiesRefCount)) { 358 | if (this._entitiesRefCount[entityID] > 0) { 359 | findReferencedEntitiesByEntity( 360 | this._entities, 361 | referencedEntities, 362 | entityID 363 | ); 364 | } 365 | } 366 | 367 | const deletedEntities = createRecord(); 368 | 369 | // Delete unreferenced entities 370 | for (const entityID of Object.keys(this._entities)) { 371 | if (!referencedEntities[entityID]) { 372 | deletedEntities[entityID] = true; 373 | delete this._entities[entityID]; 374 | } 375 | } 376 | 377 | // Remove read results from deleted entities 378 | for (const resultID of Object.keys(this._readResults)) { 379 | const result = this._readResults[resultID]!.result; 380 | if (result && deletedEntities[result.entityID]) { 381 | delete this._readResults[resultID]; 382 | } 383 | } 384 | } 385 | } 386 | 387 | function findReferencedEntitiesByEntity( 388 | entities: EntitiesRecord, 389 | referencedEntities: Record, 390 | entityID: string 391 | ): void { 392 | if (!entities[entityID] || referencedEntities[entityID]) { 393 | return; 394 | } 395 | 396 | referencedEntities[entityID] = true; 397 | 398 | findReferencedEntities( 399 | entities, 400 | referencedEntities, 401 | entities[entityID]!.value 402 | ); 403 | } 404 | 405 | function findReferencedEntities( 406 | entities: EntitiesRecord, 407 | referencedEntities: Record, 408 | value: unknown 409 | ): void { 410 | if (isReference(value)) { 411 | findReferencedEntitiesByEntity(entities, referencedEntities, value.___ref); 412 | } else if (isObject(value)) { 413 | for (const key of Object.keys(value)) { 414 | if (!isMetaKey(key)) { 415 | findReferencedEntities(entities, referencedEntities, value[key]); 416 | } 417 | } 418 | } else if (Array.isArray(value)) { 419 | for (const item of value) { 420 | findReferencedEntities(entities, referencedEntities, item); 421 | } 422 | } 423 | } 424 | 425 | function shouldReadOptimistic( 426 | cache: Cache, 427 | optimistic: boolean | undefined 428 | ): boolean { 429 | return typeof optimistic === "boolean" 430 | ? optimistic 431 | : cache._optimisticReadMode; 432 | } 433 | 434 | function shouldWriteOptimistic( 435 | cache: Cache, 436 | optimistic: boolean | undefined 437 | ): boolean { 438 | return typeof optimistic === "boolean" 439 | ? optimistic 440 | : cache._optimisticWriteMode; 441 | } 442 | 443 | function handleOptimisticUpdatesChange(cache: Cache) { 444 | rebaseOptimisticUpdates(cache); 445 | invalidateReadResults(cache); 446 | } 447 | 448 | function handleEntityUpdate(cache: Cache) { 449 | if (cache._transactions) { 450 | cache._transactionHasEntityUpdate = true; 451 | return; 452 | } 453 | rebaseOptimisticUpdates(cache); 454 | invalidateReadResults(cache); 455 | } 456 | 457 | function handleOptimisticEntityUpdate(cache: Cache) { 458 | if (cache._transactions) { 459 | cache._transactionHasOptimisticEntityUpdate = true; 460 | return; 461 | } 462 | invalidateReadResults(cache); 463 | } 464 | 465 | function invalidateReadResults(cache: Cache) { 466 | for (const key of Object.keys(cache._readResults)) { 467 | cache._readResults[key]!.invalidated = true; 468 | } 469 | 470 | if (!cache._silent) { 471 | checkWatchers(cache); 472 | } 473 | } 474 | 475 | function rebaseOptimisticUpdates(cache: Cache) { 476 | cache._optimisticEntities = createRecord(); 477 | cache.transaction(() => { 478 | for (const update of cache._optimisticUpdates) { 479 | cache.setOptimisticWriteMode(true); 480 | update.updateFn(cache); 481 | cache.setOptimisticWriteMode(false); 482 | } 483 | }); 484 | } 485 | 486 | function checkWatchers(cache: Cache) { 487 | for (const watch of cache._watches) { 488 | const prevResult = watch.prevResult; 489 | const result = cache.read(watch.options); 490 | if (result !== prevResult) { 491 | watch.prevResult = result; 492 | watch.options.callback(result, prevResult); 493 | } 494 | } 495 | } 496 | 497 | function ensureType(cache: Cache, typeName: string): ValueType { 498 | const type = cache._types[typeName]; 499 | 500 | invariant( 501 | type, 502 | process.env.NODE_ENV === "production" 503 | ? ErrorCode.TYPE_NOT_FOUND 504 | : `Type ${typeName} not found` 505 | ); 506 | 507 | return type; 508 | } 509 | 510 | function getResultID( 511 | type: ValueType, 512 | selector?: DocumentNode, 513 | id?: unknown 514 | ): string { 515 | const entityID = identify(type, id)!; 516 | return selector ? `${entityID}:${serializeSelector(selector)}` : entityID; 517 | } 518 | -------------------------------------------------------------------------------- /src/__tests__/write.spec.ts: -------------------------------------------------------------------------------- 1 | import { Cache, cql, WriteResult, schema } from ".."; 2 | 3 | describe("write", () => { 4 | it("should be able to write singleton entities", () => { 5 | const Type = schema.string({ name: "Type" }); 6 | const cache = new Cache({ types: [Type] }); 7 | const writeResult = cache.write({ type: "Type", data: "a" }); 8 | const expectedWriteResult: WriteResult = { 9 | entityID: "Type", 10 | updatedEntityIDs: ["Type"], 11 | }; 12 | expect(writeResult).toEqual(expectedWriteResult); 13 | const readResult = cache.read({ type: "Type" }); 14 | expect(readResult!.data).toEqual("a"); 15 | }); 16 | 17 | it("should be able to write entities with a specific ID", () => { 18 | const Type = schema.string({ name: "Type" }); 19 | const cache = new Cache({ types: [Type] }); 20 | cache.write({ type: "Type", id: "1", data: "a" }); 21 | const result = cache.read({ type: "Type", id: "1" }); 22 | expect(result!.data).toBe("a"); 23 | }); 24 | 25 | it("should be able to write entities with an object ID", () => { 26 | const Type = schema.string({ name: "Type" }); 27 | const cache = new Cache({ types: [Type] }); 28 | cache.write({ type: "Type", id: { page: 1 }, data: "a" }); 29 | const result = cache.read({ type: "Type", id: { page: 1 } }); 30 | expect(result!.data).toBe("a"); 31 | }); 32 | 33 | it("should be able to write boolean entites", () => { 34 | const Type = schema.boolean({ name: "Type" }); 35 | const cache = new Cache({ types: [Type] }); 36 | cache.write({ type: "Type", data: true }); 37 | const result = cache.read({ type: "Type" }); 38 | expect(result!.data).toBe(true); 39 | }); 40 | 41 | it("should be able to write null entities", () => { 42 | const Type = schema.string({ name: "Type" }); 43 | const cache = new Cache({ types: [Type] }); 44 | cache.write({ type: "Type", data: null }); 45 | const result = cache.read({ type: "Type" }); 46 | expect(result!.data).toBe(null); 47 | }); 48 | 49 | it("should be able to write string entities", () => { 50 | const Type = schema.string({ name: "Type" }); 51 | const cache = new Cache({ types: [Type] }); 52 | cache.write({ type: "Type", data: "a" }); 53 | const result = cache.read({ type: "Type" }); 54 | expect(result!.data).toBe("a"); 55 | }); 56 | 57 | it("should be able to write number entities", () => { 58 | const Type = schema.number({ name: "Type" }); 59 | const cache = new Cache({ types: [Type] }); 60 | cache.write({ type: "Type", data: 1 }); 61 | const result = cache.read({ type: "Type" }); 62 | expect(result!.data).toBe(1); 63 | }); 64 | 65 | it("should be able to write undefined entities", () => { 66 | const Type = schema.string({ name: "Type" }); 67 | const cache = new Cache({ types: [Type] }); 68 | cache.write({ type: "Type", data: undefined }); 69 | const result = cache.read({ type: "Type" }); 70 | expect(result!.data).toBe(undefined); 71 | }); 72 | 73 | it("should be able to write object entities", () => { 74 | const Type = schema.object({ name: "Type" }); 75 | const cache = new Cache({ types: [Type] }); 76 | cache.write({ type: "Type", data: {} }); 77 | const result = cache.read({ type: "Type" }); 78 | expect(result!.data).toEqual({}); 79 | }); 80 | 81 | it("should be able to write array entities", () => { 82 | const Type = schema.array({ name: "Type" }); 83 | const cache = new Cache({ types: [Type] }); 84 | cache.write({ type: "Type", data: [] }); 85 | const result = cache.read({ type: "Type" }); 86 | expect(result!.data).toEqual([]); 87 | }); 88 | 89 | it("should be able to write undefined values", () => { 90 | const Type = schema.object({ 91 | name: "Type", 92 | fields: { value: schema.string() }, 93 | }); 94 | const cache = new Cache({ types: [Type] }); 95 | cache.write({ type: "Type", data: { value: undefined } }); 96 | const result = cache.read({ type: "Type" }); 97 | expect(result!.data).toEqual({ value: undefined }); 98 | }); 99 | 100 | it("should be able to write null values", () => { 101 | const Type = schema.object({ 102 | name: "Type", 103 | fields: { value: schema.string() }, 104 | }); 105 | const cache = new Cache({ types: [Type] }); 106 | cache.write({ type: "Type", data: { value: null } }); 107 | const result = cache.read({ type: "Type" }); 108 | expect(result!.data).toEqual({ value: null }); 109 | }); 110 | 111 | it("should be able to write boolean values", () => { 112 | const Type = schema.object({ 113 | name: "Type", 114 | fields: { value: schema.boolean() }, 115 | }); 116 | const cache = new Cache({ types: [Type] }); 117 | cache.write({ type: "Type", data: { value: false } }); 118 | const result = cache.read({ type: "Type" }); 119 | expect(result!.data).toEqual({ value: false }); 120 | }); 121 | 122 | it("should be able to write number values", () => { 123 | const Type = schema.object({ 124 | name: "Type", 125 | fields: { value: schema.number() }, 126 | }); 127 | const cache = new Cache({ types: [Type] }); 128 | cache.write({ type: "Type", data: { value: 1 } }); 129 | const result = cache.read({ type: "Type" }); 130 | expect(result!.data).toEqual({ value: 1 }); 131 | }); 132 | 133 | it("should be able to write string values", () => { 134 | const Type = schema.object({ 135 | name: "Type", 136 | fields: { value: schema.string() }, 137 | }); 138 | const cache = new Cache({ types: [Type] }); 139 | cache.write({ type: "Type", data: { value: "a" } }); 140 | const result = cache.read({ type: "Type" }); 141 | expect(result!.data).toEqual({ value: "a" }); 142 | }); 143 | 144 | it("should be able to write object values", () => { 145 | const Type = schema.object({ name: "Type" }); 146 | const cache = new Cache({ types: [Type] }); 147 | cache.write({ type: "Type", data: { value: { a: "a" } } }); 148 | const result = cache.read({ type: "Type" }); 149 | expect(result!.data).toEqual({ value: { a: "a" } }); 150 | }); 151 | 152 | it("should be able to write array values", () => { 153 | const Type = schema.object({ name: "Type" }); 154 | const cache = new Cache({ types: [Type] }); 155 | cache.write({ type: "Type", data: { value: [0, 1, 2] } }); 156 | const result = cache.read({ type: "Type" }); 157 | expect(result!.data).toEqual({ value: [0, 1, 2] }); 158 | }); 159 | 160 | it("should be able to write references", () => { 161 | const Child = schema.object({ name: "Child" }); 162 | const Parent = schema.object({ name: "Parent", fields: { child: Child } }); 163 | const cache = new Cache({ types: [Parent] }); 164 | cache.write({ type: "Child", data: { id: "1" } }); 165 | cache.write({ type: "Parent", data: { child: { ___ref: "Child:1" } } }); 166 | const result = cache.read({ 167 | type: "Parent", 168 | select: cql`{ child { id } }`, 169 | }); 170 | expect(result!.data).toEqual({ child: { id: "1" } }); 171 | }); 172 | 173 | it("should normalize nested entities", () => { 174 | const Child = schema.object({ name: "Child" }); 175 | const Parent = schema.object({ name: "Parent", fields: { child: Child } }); 176 | const cache = new Cache({ types: [Parent] }); 177 | cache.write({ type: "Parent", data: { child: { id: "1" } } }); 178 | const result = cache.read({ type: "Child", id: "1" }); 179 | expect(result!.data).toEqual({ id: "1" }); 180 | expect(cache._entities).toMatchObject({ 181 | Parent: { id: "Parent", value: { child: { ___ref: "Child:1" } } }, 182 | "Child:1": { id: "Child:1", value: { id: "1" } }, 183 | }); 184 | }); 185 | 186 | it("should normalize nested entities wrapped in a non nullable type", () => { 187 | const Child = schema.object({ name: "Child" }); 188 | const Parent = schema.object({ 189 | name: "Parent", 190 | fields: { child: schema.nonNullable(Child) }, 191 | }); 192 | const cache = new Cache({ types: [Parent] }); 193 | cache.write({ type: "Parent", data: { child: { id: "1" } } }); 194 | expect(cache._entities).toMatchObject({ 195 | Parent: { id: "Parent", value: { child: { ___ref: "Child:1" } } }, 196 | "Child:1": { id: "Child:1", value: { id: "1" } }, 197 | }); 198 | }); 199 | 200 | it("should normalize input with circular references between entities", () => { 201 | const Child = schema.object({ 202 | name: "Child", 203 | fields: () => ({ child: Child }), 204 | }); 205 | const Parent = schema.object({ name: "Parent", fields: { child: Child } }); 206 | const cache = new Cache({ types: [Parent] }); 207 | const child = { id: "1" } as any; 208 | child.child = child; 209 | cache.write({ type: "Parent", data: { child } }); 210 | expect(cache._entities).toMatchObject({ 211 | Parent: { id: "Parent", value: { child: { ___ref: "Child:1" } } }, 212 | "Child:1": { 213 | id: "Child:1", 214 | value: { id: "1", child: { ___ref: "Child:1" } }, 215 | }, 216 | }); 217 | }); 218 | 219 | it("should warn when normalizing input with circular references between non-entities", () => { 220 | const Parent = schema.object({ 221 | name: "Parent", 222 | fields: { child: schema.object() }, 223 | }); 224 | const cache = new Cache({ types: [Parent] }); 225 | const child = { id: "1" } as any; 226 | child.child = child; 227 | expect(() => { 228 | cache.write({ type: "Parent", data: { child } }); 229 | }).toThrow(); 230 | }); 231 | 232 | it("should not normalize nested entities if they do not have an ID", () => { 233 | const Child = schema.object({ name: "Child" }); 234 | const Parent = schema.object({ name: "Parent", fields: { child: Child } }); 235 | const cache = new Cache({ types: [Parent] }); 236 | cache.write({ type: "Parent", data: { child: {} } }); 237 | expect(cache._entities).toMatchObject({ 238 | Parent: { id: "Parent", value: { child: {} } }, 239 | }); 240 | }); 241 | 242 | it("should not normalize nested entities if they do not have a type name", () => { 243 | const Child = schema.object(); 244 | const Parent = schema.object({ name: "Parent", fields: { child: Child } }); 245 | const cache = new Cache({ types: [Parent] }); 246 | cache.write({ type: "Parent", data: { child: { id: "1" } } }); 247 | expect(cache._entities).toMatchObject({ 248 | Parent: { id: "Parent", value: { child: { id: "1" } } }, 249 | }); 250 | }); 251 | 252 | it("should not normalize if the incoming data does not match the schema", () => { 253 | const Child = schema.object(); 254 | const Parent = schema.object({ name: "Parent", fields: { child: Child } }); 255 | const cache = new Cache({ types: [Parent] }); 256 | cache.write({ type: "Parent", data: { child: [{ id: "1" }] } }); 257 | expect(cache._entities).toMatchObject({ 258 | Parent: { id: "Parent", value: { child: [{ id: "1" }] } }, 259 | }); 260 | }); 261 | 262 | it("should normalize entities within unions with resolveType", () => { 263 | const A = schema.object({ name: "A" }); 264 | const B = schema.object({ name: "B" }); 265 | const Parent = schema.array({ 266 | name: "Parent", 267 | ofType: schema.union({ 268 | types: [A, B], 269 | resolveType: (value) => (value.type === "A" ? A : B), 270 | }), 271 | }); 272 | const cache = new Cache({ types: [Parent] }); 273 | cache.write({ 274 | type: "Parent", 275 | data: [ 276 | { id: "1", type: "A" }, 277 | { id: "1", type: "B" }, 278 | ], 279 | }); 280 | expect(cache._entities).toMatchObject({ 281 | Parent: { id: "Parent", value: [{ ___ref: "A:1" }, { ___ref: "B:1" }] }, 282 | "A:1": { id: "A:1", value: { id: "1", type: "A" } }, 283 | "B:1": { id: "B:1", value: { id: "1", type: "B" } }, 284 | }); 285 | }); 286 | 287 | it("should normalize entities within unions with isOfType", () => { 288 | const A = schema.object({ 289 | name: "A", 290 | isOfType: (value) => value.type === "A", 291 | }); 292 | const B = schema.object({ 293 | name: "B", 294 | isOfType: (value) => value.type === "B", 295 | }); 296 | const Parent = schema.array({ 297 | name: "Parent", 298 | ofType: schema.union({ types: [A, B] }), 299 | }); 300 | const cache = new Cache({ types: [Parent] }); 301 | cache.write({ 302 | type: "Parent", 303 | data: [ 304 | { id: "1", type: "A" }, 305 | { id: "1", type: "B" }, 306 | ], 307 | }); 308 | expect(cache._entities).toMatchObject({ 309 | Parent: { id: "Parent", value: [{ ___ref: "A:1" }, { ___ref: "B:1" }] }, 310 | "A:1": { id: "A:1", value: { id: "1", type: "A" } }, 311 | "B:1": { id: "B:1", value: { id: "1", type: "B" } }, 312 | }); 313 | }); 314 | 315 | it("should merge if the same entity is found multiple times", () => { 316 | const Child = schema.object({ name: "Child" }); 317 | const Parent = schema.object({ 318 | name: "Parent", 319 | fields: { child1: Child, child2: Child }, 320 | }); 321 | const cache = new Cache({ types: [Parent] }); 322 | cache.write({ 323 | type: "Parent", 324 | data: { 325 | child1: { id: "1", a: "a" }, 326 | child2: { id: "1", b: "b" }, 327 | }, 328 | }); 329 | expect(cache._entities).toMatchObject({ 330 | Parent: { 331 | id: "Parent", 332 | value: { child1: { ___ref: "Child:1" }, child2: { ___ref: "Child:1" } }, 333 | }, 334 | "Child:1": { id: "Child:1", value: { id: "1", a: "a", b: "b" } }, 335 | }); 336 | }); 337 | 338 | it("should merge if the same entity is found within itself", () => { 339 | const Child = schema.object({ 340 | name: "Child", 341 | fields: () => ({ child: Child }), 342 | }); 343 | const Parent = schema.object({ name: "Parent", fields: { child: Child } }); 344 | const cache = new Cache({ types: [Parent] }); 345 | cache.write({ 346 | type: "Parent", 347 | data: { 348 | child: { id: "1", a: "a", child: { id: "1", b: "b" } }, 349 | }, 350 | }); 351 | expect(cache._entities).toMatchObject({ 352 | Parent: { id: "Parent", value: { child: { ___ref: "Child:1" } } }, 353 | "Child:1": { 354 | id: "Child:1", 355 | value: { id: "1", a: "a", b: "b", child: { ___ref: "Child:1" } }, 356 | }, 357 | }); 358 | }); 359 | 360 | it("should merge existing entities", () => { 361 | const Child = schema.object({ name: "Child" }); 362 | const Parent = schema.object({ name: "Parent", fields: { child: Child } }); 363 | const cache = new Cache({ types: [Parent] }); 364 | cache.write({ type: "Parent", data: { child: { id: "1", a: "a" } } }); 365 | cache.write({ type: "Parent", data: { child: { id: "1", b: "b" } } }); 366 | expect(cache._entities).toMatchObject({ 367 | Parent: { id: "Parent", value: { child: { ___ref: "Child:1" } } }, 368 | "Child:1": { id: "Child:1", value: { id: "1", a: "a", b: "b" } }, 369 | }); 370 | }); 371 | 372 | it("should not merge existing objects without an ID", () => { 373 | const Child = schema.object({ name: "Child" }); 374 | const Parent = schema.object({ name: "Parent", fields: { child: Child } }); 375 | const cache = new Cache({ types: [Parent] }); 376 | cache.write({ type: "Parent", data: { child: { a: "a" } } }); 377 | cache.write({ type: "Parent", data: { child: { b: "b" } } }); 378 | expect(cache._entities).toMatchObject({ 379 | Parent: { id: "Parent", value: { child: { b: "b" } } }, 380 | }); 381 | }); 382 | 383 | it("should not merge existing arrays", () => { 384 | const Child = schema.object({ name: "Child" }); 385 | const Parent = schema.array({ name: "Parent", ofType: Child }); 386 | const cache = new Cache({ types: [Parent] }); 387 | cache.write({ 388 | type: "Parent", 389 | data: [ 390 | { id: "1", a: "a" }, 391 | { id: "2", b: "b" }, 392 | ], 393 | }); 394 | cache.write({ type: "Parent", data: [{ id: "2", b: "b" }] }); 395 | expect(cache._entities).toMatchObject({ 396 | Parent: { id: "Parent", value: [{ ___ref: "Child:2" }] }, 397 | "Child:1": { id: "Child:1", value: { id: "1", a: "a" } }, 398 | "Child:2": { id: "Child:2", value: { id: "2", b: "b" } }, 399 | }); 400 | }); 401 | 402 | it("should call user defined write functions on types", () => { 403 | const Child = schema.object({ 404 | name: "Child", 405 | write: (a, b) => ({ ...a, ...b }), 406 | }); 407 | const Parent = schema.object({ name: "Parent", fields: { child: Child } }); 408 | const cache = new Cache({ types: [Parent] }); 409 | cache.write({ type: "Parent", data: { child: { a: "a" } } }); 410 | cache.write({ type: "Parent", data: { child: { b: "b" } } }); 411 | expect(cache._entities).toMatchObject({ 412 | Parent: { id: "Parent", value: { child: { a: "a", b: "b" } } }, 413 | }); 414 | }); 415 | 416 | it("should call nested user defined write functions on types", () => { 417 | const SubChild = schema.object({ 418 | write: (incoming, existing) => ({ ...existing, ...incoming }), 419 | }); 420 | const Child = schema.object({ 421 | name: "Child", 422 | write: (incoming, existing) => ({ ...existing, ...incoming }), 423 | fields: { 424 | subChild: SubChild, 425 | }, 426 | }); 427 | const Parent = schema.object({ name: "Parent", fields: { child: Child } }); 428 | const cache = new Cache({ types: [Parent] }); 429 | cache.write({ 430 | type: "Parent", 431 | data: { child: { a: "a", subChild: { a: "a" } } }, 432 | }); 433 | cache.write({ 434 | type: "Parent", 435 | data: { child: { b: "b", subChild: { b: "b" } } }, 436 | }); 437 | expect(cache._entities).toMatchObject({ 438 | Parent: { 439 | id: "Parent", 440 | value: { child: { a: "a", b: "b", subChild: { a: "a", b: "b" } } }, 441 | }, 442 | }); 443 | }); 444 | 445 | it("should call user defined write functions on fields", () => { 446 | const Child = schema.object({ 447 | name: "Child", 448 | fields: { 449 | a: { 450 | write: (incoming) => `In:${incoming}`, 451 | }, 452 | }, 453 | }); 454 | const Parent = schema.object({ name: "Parent", fields: { child: Child } }); 455 | const cache = new Cache({ types: [Parent] }); 456 | cache.write({ type: "Parent", data: { child: { a: "a" } } }); 457 | expect(cache._entities).toMatchObject({ 458 | Parent: { id: "Parent", value: { child: { a: "In:a" } } }, 459 | }); 460 | }); 461 | 462 | it("should be able to replace an entity with a write function", () => { 463 | const Child = schema.object({ 464 | name: "Child", 465 | write: (incoming) => incoming, 466 | }); 467 | const Parent = schema.object({ name: "Parent", fields: { child: Child } }); 468 | const cache = new Cache({ types: [Parent] }); 469 | cache.write({ type: "Parent", data: { child: { a: "a" } } }); 470 | cache.write({ type: "Parent", data: { child: { b: "b" } } }); 471 | expect(cache._entities).toMatchObject({ 472 | Parent: { id: "Parent", value: { child: { b: "b" } } }, 473 | }); 474 | }); 475 | 476 | it("should share structures if new writes are similar", () => { 477 | const Child = schema.object({ name: "Child" }); 478 | const Parent = schema.object({ name: "Parent", fields: { child: Child } }); 479 | const cache = new Cache({ types: [Parent] }); 480 | cache.write({ type: "Parent", data: { child: { a: "a" } } }); 481 | const result1 = cache.read({ type: "Parent" }); 482 | cache.write({ type: "Parent", data: { child: { a: "a" } } }); 483 | const result2 = cache.read({ type: "Parent" }); 484 | expect(result1!.data).toBe(result2!.data); 485 | }); 486 | 487 | it("should be able to write optimistic data", () => { 488 | const Child = schema.object({ name: "Child" }); 489 | const Parent = schema.object({ name: "Parent", fields: { child: Child } }); 490 | const cache = new Cache({ types: [Parent] }); 491 | cache.write({ type: "Parent", data: { child: { id: "1", a: "a" } } }); 492 | cache.write({ 493 | type: "Parent", 494 | data: { child: { id: "1", b: "b" } }, 495 | optimistic: true, 496 | }); 497 | expect(cache._entities).toMatchObject({ 498 | Parent: { id: "Parent", value: { child: { ___ref: "Child:1" } } }, 499 | "Child:1": { id: "Child:1", value: { id: "1", a: "a" } }, 500 | }); 501 | expect(cache._optimisticEntities).toMatchObject({ 502 | Parent: { id: "Parent", value: { child: { ___ref: "Child:1" } } }, 503 | "Child:1": { id: "Child:1", value: { id: "1", a: "a", b: "b" } }, 504 | }); 505 | }); 506 | 507 | it("should be write optimistically by default when optimistic mode is enabled", () => { 508 | const Child = schema.object({ name: "Child" }); 509 | const Parent = schema.object({ name: "Parent", fields: { child: Child } }); 510 | const cache = new Cache({ types: [Parent] }); 511 | cache.write({ type: "Parent", data: { child: { id: "1", a: "a" } } }); 512 | cache.setOptimisticWriteMode(true); 513 | cache.write({ type: "Parent", data: { child: { id: "1", b: "b" } } }); 514 | expect(cache._optimisticEntities).toMatchObject({ 515 | Parent: { id: "Parent", value: { child: { ___ref: "Child:1" } } }, 516 | "Child:1": { id: "Child:1", value: { id: "1", a: "a", b: "b" } }, 517 | }); 518 | }); 519 | 520 | it("should be able to add an entity to an array of entities", () => { 521 | const Child = schema.object({ name: "Child" }); 522 | const Parent = schema.array({ name: "Parent", ofType: Child }); 523 | const cache = new Cache({ types: [Parent] }); 524 | cache.write({ 525 | type: "Parent", 526 | data: [ 527 | { id: "1", a: "a" }, 528 | { id: "2", b: "b" }, 529 | ], 530 | }); 531 | const read1 = cache.read({ type: "Parent", select: cql`{ id }` }); 532 | cache.write({ 533 | type: "Parent", 534 | data: [...read1!.data, { id: "3", c: "c" }], 535 | }); 536 | const read2 = cache.read({ type: "Parent" }); 537 | expect(read2!.data).toEqual([ 538 | { id: "1", a: "a" }, 539 | { id: "2", b: "b" }, 540 | { id: "3", c: "c" }, 541 | ]); 542 | }); 543 | 544 | it("should be able to write fields with arguments", () => { 545 | const Parent = schema.object({ name: "Parent" }); 546 | const cache = new Cache({ types: [Parent] }); 547 | cache.write({ 548 | type: "Parent", 549 | data: { 'child({"id":1})': { id: "1", a: "a" } }, 550 | }); 551 | expect(cache._entities).toMatchObject({ 552 | Parent: { 553 | id: "Parent", 554 | value: { 'child({"id":1})': { id: "1", a: "a" } }, 555 | }, 556 | }); 557 | }); 558 | 559 | it("should normalize entities within fields with arguments", () => { 560 | const Child = schema.object({ name: "Child" }); 561 | const Parent = schema.object({ 562 | name: "Parent", 563 | fields: { 564 | child: { type: Child, arguments: true }, 565 | }, 566 | }); 567 | const cache = new Cache({ types: [Parent] }); 568 | cache.write({ 569 | type: "Parent", 570 | data: { 'child({"id":1})': { id: "1", a: "a" } }, 571 | }); 572 | expect(cache._entities).toMatchObject({ 573 | Parent: { 574 | id: "Parent", 575 | value: { 'child({"id":1})': { ___ref: "Child:1" } }, 576 | }, 577 | "Child:1": { id: "Child:1", value: { id: "1", a: "a" } }, 578 | }); 579 | }); 580 | 581 | it("should not normalize entities within fields with arguments if args is not set", () => { 582 | const Child = schema.object({ name: "Child" }); 583 | const Parent = schema.object({ 584 | name: "Parent", 585 | fields: { child: Child }, 586 | }); 587 | const cache = new Cache({ types: [Parent] }); 588 | cache.write({ 589 | type: "Parent", 590 | data: { 'child({"id":1})': { id: "1", a: "a" } }, 591 | }); 592 | expect(cache._entities).toMatchObject({ 593 | Parent: { 594 | id: "Parent", 595 | value: { 'child({"id":1})': { id: "1", a: "a" } }, 596 | }, 597 | }); 598 | }); 599 | 600 | it("should not return a selector if the entity is not selectable", () => { 601 | const Type = schema.string({ name: "Type" }); 602 | const cache = new Cache({ types: [Type] }); 603 | const { selector } = cache.write({ type: "Type", data: "a" }); 604 | expect(selector).toBeUndefined(); 605 | }); 606 | 607 | it("should return a selector matching the input shape", () => { 608 | const Type = schema.object({ name: "Type" }); 609 | const cache = new Cache({ types: [Type] }); 610 | const data = { a: "a" }; 611 | const { selector } = cache.write({ type: "Type", data }); 612 | const result = cache.read({ type: "Type", select: selector }); 613 | expect(result!.data).toEqual(data); 614 | expect(result!.invalidFields).toBeUndefined(); 615 | expect(result!.missingFields).toBeUndefined(); 616 | }); 617 | 618 | it("should return a selector matching the combined input shape in arrays", () => { 619 | const Type = schema.object({ name: "Type" }); 620 | const cache = new Cache({ types: [Type] }); 621 | const data = { array: [{ a: "a" }, { b: "b" }] }; 622 | const { selector } = cache.write({ type: "Type", data }); 623 | const result = cache.read({ type: "Type", select: selector }); 624 | expect(result!.data).toEqual(data); 625 | expect(result!.invalidFields).toBeUndefined(); 626 | expect(result!.missingFields).toEqual([ 627 | { path: ["array", 0, "b"] }, 628 | { path: ["array", 1, "a"] }, 629 | ]); 630 | }); 631 | 632 | it("should return a selector matching the individual input shape in arrays", () => { 633 | const A = schema.object({ name: "A", isOfType: (value) => value?.a }); 634 | const B = schema.object({ name: "B", isOfType: (value) => value?.b }); 635 | const Type = schema.object({ 636 | name: "Type", 637 | fields: { 638 | array: schema.array(schema.union([A, B])), 639 | }, 640 | }); 641 | const cache = new Cache({ types: [Type] }); 642 | const data = { array: [{ a: "a" }, { b: "b" }] }; 643 | const { selector } = cache.write({ type: "Type", data }); 644 | const result = cache.read({ type: "Type", select: selector }); 645 | expect(result!.data).toEqual(data); 646 | expect(result!.invalidFields).toBeUndefined(); 647 | expect(result!.missingFields).toBeUndefined(); 648 | }); 649 | 650 | it("should return a selector which only selects the input fields from related entities", () => { 651 | const A = schema.object({ name: "A" }); 652 | const Type = schema.object({ 653 | name: "Type", 654 | fields: { 655 | a1: A, 656 | a2: A, 657 | }, 658 | }); 659 | const cache = new Cache({ types: [Type] }); 660 | cache.write({ type: "A", id: "1", data: { id: "1", aa: "aa" } }); 661 | const data = { a1: { id: "1", a: "a" }, a2: { id: "2", b: "b" } }; 662 | const { selector } = cache.write({ type: "Type", data }); 663 | const result = cache.read({ type: "Type", select: selector }); 664 | expect(result!.data).toEqual(data); 665 | expect(result!.invalidFields).toBeUndefined(); 666 | expect(result!.missingFields).toBeUndefined(); 667 | }); 668 | 669 | it("should return a selector which only selects the input fields from related entities in arrays", () => { 670 | const A = schema.object({ 671 | name: "A", 672 | fields: { 673 | id: schema.string(), 674 | a: schema.string(), 675 | aa: schema.string(), 676 | b: schema.string(), 677 | }, 678 | }); 679 | const Type = schema.object({ 680 | name: "Type", 681 | fields: { 682 | array: [A], 683 | }, 684 | }); 685 | const cache = new Cache({ types: [Type] }); 686 | cache.write({ type: "A", id: "1", data: { id: "1", aa: "aa" } }); 687 | const data = { 688 | array: [ 689 | { id: "1", a: "a" }, 690 | { id: "2", b: "b" }, 691 | ], 692 | }; 693 | const { selector } = cache.write({ type: "Type", data }); 694 | const result = cache.read({ type: "Type", select: selector }); 695 | expect(result!.data).toEqual(data); 696 | expect(result!.invalidFields).toBeUndefined(); 697 | expect(result!.missingFields).toEqual([ 698 | { path: ["array", 0, "b"] }, 699 | { path: ["array", 1, "a"] }, 700 | ]); 701 | }); 702 | }); 703 | --------------------------------------------------------------------------------