Change from pnpm to npm, add ./link.sh shortcut for npm style package linking
[build_cache.git] / BuildCache.js
1 /*
2  * Copyright (C) 2018 Nick Downing <nick@ndcode.org>
3  * SPDX-License-Identifier: MIT
4  *
5  * Permission is hereby granted, free of charge, to any person obtaining a copy
6  * of this software and associated documentation files (the "Software"), to
7  * deal in the Software without restriction, including without limitation the
8  * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
9  * sell copies of the Software, and to permit persons to whom the Software is
10  * furnished to do so, subject to the following conditions:
11  *
12  * The above copyright notice and this permission notice shall be included in
13  * all copies or substantial portions of the Software.
14  *
15  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18  * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
20  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
21  * IN THE SOFTWARE.
22  */
23
24 let fs = require('fs')
25 let util = require('util')
26
27 let fs_stat = util.promisify(fs.stat)
28
29 /**
30  * Constructs a cache object. The cache object is intended to store objects of
31  * arbitrary JavaScript type, which are built from on-disk source files of some
32  * kind. The cache tracks the source files of each object, and makes sure the
33  * objects are rebuilt as required if the source files change on disk.
34  *
35  * @constructor
36  * @param {boolean} diag - Should diagnostic messages be printed to the
37  *   console.
38  */
39 let BuildCache = function(diag) {
40   if (!this instanceof BuildCache)
41     throw new Error('BuildCache is a constructor')
42   this.map = new Map()
43   this.diag = diag || false
44 }
45
46 /**
47  * Abstract method which is expected to build and return an object, given its
48  * key. Called from "get()" when the object does not exist or is out of date.
49  *
50  * If this method throws an exception, the key will be deleted from the cache
51  * and the exception re-thrown to the caller of "get()". If there are multiple
52  * callers to "get()" blocking and waiting for the build, they all receive the
53  * same exception object. So one has to be careful the exception is shareable.
54  *
55  * @method
56  * @param {string} key - Usually the path to the main source file on disk.
57  * @param {object} result - A dictionary to receive information about the built
58  *   object, you can optionally set "result.deps" to a list of dependency files
59  *   whose modification would invalidate the just-built and cached object.
60  */
61 BuildCache.prototype.build = async function(key, result) {
62   throw new Error('not implemented')
63 }
64
65 /**
66  * Retrieves the object stored in the cache under "key". If "key" already
67  * exists in the cache, then it will be checked for up-to-dateness. If present
68  * and up-to-date then its object is returned directly. Otherwise the abstract
69  * "build()" method is called to attempt to build the object, and either an
70  * exception is thrown or the built object is stored and returned to the
71  * caller.
72  *
73  * Other callers requsting the same object while the original build progresses
74  * will be blocked, and all will wait for the build to complete. In this time,
75  * no new up-to-date check will be initiated. But as soon as the build is
76  * completed and the cache updated, further up-to-date checks become possible.
77  *
78  * An interesting alternate usage is provided for objects whose contents only
79  * matter if they have been rebuilt since last time. For example, suppose we
80  * want to periodically read a configuration file, and then possibly restart
81  * some long-running process if the configuration has changed. Then it is not
82  * necessary to store the result of configuration parsing in the cache, since
83  * it is only needed momentarily (while we're actually restarting the process).
84  * In such case, pass "once = true" and an "undefined" return means no change.
85  *
86  * @method
87  * @param {string} key - Usually the path to the main source file on disk.
88  * @param {boolean} once - If "true", it means the returned object will only be
89  *   used once. See above for a more comprehensive discussion of this feature.
90  */
91 BuildCache.prototype.get = async function(key, once) {
92   let result = this.map.get(key)
93   if (result === undefined) {
94     if (this.diag)
95       console.log(`building ${key}`)
96     result = {deps: [key], time: Date.now()}
97     result.done = this.build(key, result)
98     this.map.set(key, result)
99     try {
100       await result.done
101     }
102     catch (err) {
103       delete result.done
104       this.map.delete(key)
105       throw err
106     }
107     delete result.done
108   }
109   else if (result.done === undefined) {
110     if (this.diag)
111       console.log(`checking ${key}`)
112     result.done = (
113       async () => {
114         for (let i = 0; i < result.deps.length; ++i) {
115           let stats
116           try {
117             stats = await fs_stat(result.deps[i])
118           }
119           catch (err) {
120             if (!(err instanceof Error) || err.code !== 'ENOENT')
121               throw err
122             //stats = undefined
123           }
124           if (stats === undefined || stats.mtimeMs > result.time) {
125             if (this.diag)
126               console.log(`rebuilding ${key} reason ${result.deps[i]}`)
127             result.deps = [key]
128             result.time = Date.now()
129             await this.build(key, result)
130             break
131           }
132         }
133       }
134     )()
135     try {
136       await result.done
137     }
138     catch (err) {
139       delete result.done
140       this.map.delete(key)
141       throw err
142     }
143     delete result.done
144   }
145   else
146     await result.done
147   let value = result.value
148   if (once)
149     result.value = undefined
150   return value
151 }
152
153 /**
154  * Call this periodically to allow the cache to clean itself of stale objects.
155  *
156  * The cache cleaning is not yet implemented, but the dummy "kick()" function
157  * is provided so that you can start to put the cleaning infrastructure in your
158  * code already. The constructor arguments might change later for this feature.
159  *
160  * @method
161  */
162 BuildCache.prototype.kick = function() {
163   // not yet implemented
164 }
165
166 module.exports = BuildCache