@weborigami/language 0.6.17 → 0.7.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/index.ts +1 -0
  2. package/main.js +7 -1
  3. package/package.json +6 -6
  4. package/src/compiler/compile.js +10 -3
  5. package/src/compiler/optimize.js +71 -40
  6. package/src/compiler/parse.js +1 -1
  7. package/src/compiler/parserHelpers.js +5 -3
  8. package/src/handlers/getSource.js +11 -0
  9. package/src/handlers/htm_handler.js +1 -1
  10. package/src/handlers/js_handler.js +13 -4
  11. package/src/handlers/mediaTypeExtensions.json +15 -0
  12. package/src/handlers/ori_handler.js +8 -7
  13. package/src/handlers/oridocument_handler.js +17 -10
  14. package/src/handlers/processOriExport.js +17 -0
  15. package/src/handlers/tsv_handler.js +1 -1
  16. package/src/handlers/txt_handler.js +4 -2
  17. package/src/handlers/xhtml_handler.js +1 -1
  18. package/src/handlers/yaml_handler.js +6 -3
  19. package/src/project/activeProjectRoot.js +9 -0
  20. package/src/project/getGlobalsForTree.js +5 -0
  21. package/src/project/{projectGlobals.js → initializeGlobalsForTree.js} +8 -13
  22. package/src/project/jsGlobals.js +1 -0
  23. package/src/project/projectConfig.js +2 -2
  24. package/src/project/projectRootFromPath.js +2 -0
  25. package/src/protocols/constructHref.js +3 -3
  26. package/src/protocols/constructSiteTree.js +11 -2
  27. package/src/protocols/explore.js +1 -1
  28. package/src/protocols/explorehttp.js +1 -1
  29. package/src/protocols/fetchAndHandleExtension.js +23 -11
  30. package/src/protocols/files.js +1 -0
  31. package/src/protocols/http.js +4 -1
  32. package/src/protocols/https.js +4 -1
  33. package/src/protocols/httpstree.js +1 -1
  34. package/src/protocols/httptree.js +1 -1
  35. package/src/protocols/package.js +15 -3
  36. package/src/runtime/AsyncCacheTransform.d.ts +5 -0
  37. package/src/runtime/AsyncCacheTransform.js +134 -0
  38. package/src/runtime/HandleExtensionsTransform.d.ts +3 -1
  39. package/src/runtime/HandleExtensionsTransform.js +18 -2
  40. package/src/runtime/OrigamiFileMap.d.ts +5 -2
  41. package/src/runtime/OrigamiFileMap.js +27 -4
  42. package/src/runtime/ScopeMap.js +72 -0
  43. package/src/runtime/SyncCacheTransform.d.ts +8 -0
  44. package/src/runtime/SyncCacheTransform.js +133 -0
  45. package/src/runtime/SystemCacheMap.js +259 -0
  46. package/src/runtime/WatchFilesMixin.js +52 -19
  47. package/src/runtime/enableValueCaching.js +192 -0
  48. package/src/runtime/execute.js +2 -2
  49. package/src/runtime/executionContext.js +7 -0
  50. package/src/runtime/explainReferenceError.js +7 -2
  51. package/src/runtime/expressionObject.js +54 -46
  52. package/src/runtime/handleExtension.js +65 -34
  53. package/src/runtime/interop.js +2 -2
  54. package/src/runtime/mergeTrees.js +1 -1
  55. package/src/runtime/ops.js +28 -33
  56. package/src/runtime/symbols.js +3 -0
  57. package/src/runtime/systemCache.js +3 -0
  58. package/src/runtime/volatile.js +14 -0
  59. package/test/compiler/codeHelpers.js +2 -1
  60. package/test/compiler/optimize.test.js +62 -54
  61. package/test/handlers/ori_handler.test.js +22 -3
  62. package/test/handlers/oridocument_handler.test.js +1 -1
  63. package/test/protocols/https.test.js +19 -0
  64. package/test/protocols/package.test.js +7 -2
  65. package/test/runtime/AsyncCacheTransform.test.js +91 -0
  66. package/test/runtime/OrigamiFileMap.test.js +26 -23
  67. package/test/runtime/ScopeMap.test.js +49 -0
  68. package/test/runtime/SyncCacheTransform.test.js +93 -0
  69. package/test/runtime/SystemCacheMap.test.js +239 -0
  70. package/test/runtime/asyncCalcs.js +28 -0
  71. package/test/runtime/enableValueCaching.test.js +55 -0
  72. package/test/runtime/errors.test.js +53 -30
  73. package/test/runtime/evaluate.test.js +9 -4
  74. package/test/runtime/execute.test.js +6 -1
  75. package/test/runtime/expressionObject.test.js +55 -15
  76. package/test/runtime/fetchAndHandleExtension.test.js +24 -0
  77. package/test/runtime/fixtures/unpack/hello.json +1 -0
  78. package/test/runtime/handleExtension.test.js +12 -1
  79. package/test/runtime/ops.test.js +70 -65
  80. package/test/runtime/syncCalcs.js +27 -0
  81. package/test/runtime/systemCache.test.js +66 -0
  82. package/src/runtime/assignPropertyDescriptors.js +0 -23
  83. package/src/runtime/asyncStorage.js +0 -7
@@ -0,0 +1,133 @@
1
+ import enableValueCaching from "./enableValueCaching.js";
2
+ import { cachePathSymbol, noCacheSymbol } from "./symbols.js";
3
+ import systemCache from "./systemCache.js";
4
+ import SystemCacheMap from "./SystemCacheMap.js";
5
+
6
+ /**
7
+ * General-purpose mixin for Origami maps with dependency tracking, used for:
8
+ * files, site resources, and scope references in Origami files
9
+ *
10
+ * This wraps a map's get() and keys() methods to add caching and dependency tracking.
11
+ * It tracks which cached values are downstream of other cached values so that if
12
+ * an upstream value changes, all dependent downstream cached values can be
13
+ * invalidated efficiently.
14
+ *
15
+ * Cache entries look like:
16
+ *
17
+ * key -> {
18
+ * downstreams: Set(path),
19
+ * value
20
+ * }
21
+ *
22
+ * This allows for efficiently evicting all a value and all its downstream
23
+ * dependent cached values.
24
+ *
25
+ * Example project:
26
+ *
27
+ * site.ori loads a.ori and b.ori
28
+ * a.ori loads c.ori
29
+ * b.ori loads c.ori
30
+ * c.ori doesn't load anything
31
+ *
32
+ * Resulting cache:
33
+ *
34
+ * site.ori -> { value: ... }
35
+ * a.ori -> { downstreams: Set(site.ori), value: ... }
36
+ * b.ori -> { downstreams: Set(site.ori), value: ... }
37
+ * c.ori -> { downstreams: Set(a.ori, b.ori), value: ... }
38
+ */
39
+ export default function SyncCacheTransform(Base) {
40
+ return class SyncCache extends Base {
41
+ constructor(...args) {
42
+ super(...args);
43
+
44
+ // Expose cache for debugging
45
+ this.cache = systemCache;
46
+ }
47
+
48
+ get cachePath() {
49
+ // @ts-ignore
50
+ return this[cachePathSymbol];
51
+ }
52
+
53
+ cachePathForKey(key) {
54
+ return key === "."
55
+ ? this.cachePath
56
+ : SystemCacheMap.joinPath(this.cachePath, key);
57
+ }
58
+
59
+ delete(key) {
60
+ const deleted = super.delete(key);
61
+ if (typeof key === "string") {
62
+ systemCache.delete(this.cachePathForKey(key));
63
+ if (deleted) {
64
+ // Deleted an existing key, need to invalidate cached keys
65
+ this.invalidateKeys();
66
+ }
67
+ }
68
+ return deleted;
69
+ }
70
+
71
+ get(key) {
72
+ if (typeof key !== "string" || key.length === 0) {
73
+ // Non-string keys and non-empty strings can't be cached
74
+ return super.get(key);
75
+ }
76
+ const cachePath = this.cachePathForKey(key);
77
+ const value = systemCache.getOrInsertComputed(cachePath, () => {
78
+ let result = super.get(key);
79
+ if (result !== undefined) {
80
+ // @ts-ignore
81
+ if (this[noCacheSymbol]) {
82
+ result[noCacheSymbol] = true;
83
+ } else {
84
+ result = enableValueCaching(result, cachePath);
85
+ }
86
+ }
87
+ return result;
88
+ });
89
+ return value;
90
+ }
91
+
92
+ invalidateKeys() {
93
+ const keysPath = this.cachePathForKey("_keys");
94
+ systemCache.delete(keysPath);
95
+ }
96
+
97
+ *keys() {
98
+ const keysPath = this.cachePathForKey("_keys");
99
+ const keys = systemCache.getOrInsertComputed(keysPath, () =>
100
+ // We can't cache an iterator; convert to array
101
+ Array.from(super.keys()),
102
+ );
103
+ yield* keys;
104
+ }
105
+
106
+ onKeysChange(key) {
107
+ super.onKeysChange?.(key);
108
+ this.invalidateKeys();
109
+ }
110
+
111
+ onValueChange(key) {
112
+ super.onValueChange?.(key);
113
+ systemCache.delete(this.cachePathForKey(key));
114
+ }
115
+
116
+ set(key, value) {
117
+ if (!this._self) {
118
+ // Initializing in constructor
119
+ super.set(key, value);
120
+ return;
121
+ }
122
+ if (typeof key !== "string") {
123
+ return super.set(key, value);
124
+ }
125
+ systemCache.delete(this.cachePathForKey(key));
126
+ if (!this.has(key)) {
127
+ // Adding a new key, need to invalidate cached keys
128
+ this.invalidateKeys();
129
+ }
130
+ super.set(key, value);
131
+ }
132
+ };
133
+ }
@@ -0,0 +1,259 @@
1
+ import { SyncMap, trailingSlash } from "@weborigami/async-tree";
2
+ import { AsyncLocalStorage } from "node:async_hooks";
3
+ import { noCacheSymbol, volatileSymbol } from "./symbols.js";
4
+
5
+ // Async storage for tracking dependencies encountered during function evaluation
6
+ const asyncStorage = new AsyncLocalStorage();
7
+
8
+ // Sync analogue to AsyncLocalStorage for tracking dependencies in sync functions
9
+ const syncStorage = {
10
+ getStore() {
11
+ return this.stack.at(-1);
12
+ },
13
+
14
+ run(context, fn) {
15
+ this.stack.push(context);
16
+ const value = fn();
17
+ this.stack.pop();
18
+ return value;
19
+ },
20
+
21
+ /** @type {any[]} */
22
+ stack: [],
23
+ };
24
+
25
+ // For choosing a quasi-unique path for maps without a `cachePath` property
26
+ let nextPathId = 0;
27
+
28
+ export default class SystemCacheMap extends SyncMap {
29
+ delete(path) {
30
+ // Find all entries that depend on this path directly or indirectly
31
+ const toDelete = this.dependentEntries(path);
32
+
33
+ // Delete those dependent entries
34
+ for (const deletePath of toDelete.keys()) {
35
+ super.delete(deletePath);
36
+ }
37
+
38
+ // Remove deleted entries as being downstream from still-existing entries
39
+ for (const [deletePath, deleteEntry] of toDelete.entries()) {
40
+ for (const upstreamPath of deleteEntry.upstreams ?? []) {
41
+ const upstreamEntry = this.get(upstreamPath);
42
+ if (upstreamEntry?.downstreams) {
43
+ upstreamEntry.downstreams.delete(deletePath);
44
+ if (upstreamEntry.downstreams.size === 0) {
45
+ // No more downstream dependencies, clean up entry
46
+ delete upstreamEntry.downstreams;
47
+ }
48
+ }
49
+ }
50
+ }
51
+
52
+ return true;
53
+ }
54
+
55
+ // Return all entries that directly or indirectly depend on the given path
56
+ dependentEntries(path) {
57
+ const result = new Map();
58
+
59
+ const entry = this.get(path);
60
+ if (entry) {
61
+ // Path itself has an entry
62
+ result.set(path, entry);
63
+ }
64
+
65
+ // Add all entries with child paths that implicitly depend on this entry
66
+ for (const [otherPath, otherEntry] of this.entries()) {
67
+ if (this.isChildPath(path, otherPath)) {
68
+ result.set(otherPath, otherEntry);
69
+ }
70
+ }
71
+
72
+ // For each entry, add all entries downstream of it
73
+ for (const entry of result.values()) {
74
+ for (const downstreamPath of entry.downstreams ?? []) {
75
+ if (!result.has(downstreamPath)) {
76
+ for (const [key, value] of this.dependentEntries(downstreamPath)) {
77
+ if (!result.has(key)) {
78
+ result.set(key, value);
79
+ }
80
+ }
81
+ }
82
+ }
83
+ }
84
+
85
+ return result;
86
+ }
87
+
88
+ // REVIEW: This doesn't have the correct signature for getOrInsertComputed,
89
+ // because it returns the entry `value` property, not the actual entry.
90
+ getOrInsertComputed(path, computeFn) {
91
+ let entry = this.get(path);
92
+
93
+ if (entry && "value" in entry) {
94
+ // Cache hit, value already computed
95
+ this.trackCurrentDependency(path, entry);
96
+ return entry.value;
97
+ }
98
+
99
+ // Cache miss, or entry has no value yet
100
+ let value;
101
+
102
+ if (!entry) {
103
+ // Create empty entry for this path
104
+ entry = {};
105
+ this.set(path, entry);
106
+ }
107
+
108
+ // Create new sync context to track entries downstream of this value
109
+ const context = { downstream: path };
110
+
111
+ // Get value in sync context
112
+ value = syncStorage.run(context, computeFn);
113
+ if (value?.[noCacheSymbol] || value?.[volatileSymbol]) {
114
+ // Don't cache value
115
+ delete entry.value;
116
+ } else {
117
+ // Add resolved value to cache
118
+ entry.value = value;
119
+ }
120
+
121
+ this.trackCurrentDependency(path, entry);
122
+
123
+ return value;
124
+ }
125
+
126
+ async getOrInsertComputedAsync(path, computeFn) {
127
+ let entry = this.get(path);
128
+
129
+ if (entry && "value" in entry) {
130
+ // Cache hit, value already computed
131
+ this.trackCurrentDependency(path, entry);
132
+ return entry.value;
133
+ }
134
+
135
+ // Cache miss, or entry has no value yet
136
+ if (syncStorage.getStore()) {
137
+ // A function that was supposed to be sync called an async function
138
+ throw new Error("Cannot track async dependencies in a sync context");
139
+ }
140
+
141
+ if (!entry) {
142
+ // Create empty entry for this path
143
+ entry = {};
144
+ this.set(path, entry);
145
+ }
146
+
147
+ // Create new async context to track entries downstream of this value
148
+ const context = { downstream: path };
149
+
150
+ // Get value in async context, don't await the result yet. Add promise to
151
+ // cache so concurrent requests get the same promise.
152
+ entry.value = asyncStorage.run(context, async () => {
153
+ const value = await computeFn();
154
+ if (value?.[noCacheSymbol] || value?.[volatileSymbol]) {
155
+ // Don't cache value
156
+ delete entry.value;
157
+ } else {
158
+ // Add resolved value to cache
159
+ entry.value = value;
160
+ }
161
+ return value;
162
+ });
163
+
164
+ this.trackCurrentDependency(path, entry);
165
+
166
+ return entry.value;
167
+ }
168
+
169
+ /**
170
+ * Like standard path.join(), but without special handling for absolute or
171
+ * relative paths: adding "/", ".", or ".." adds those strings to the path.
172
+ * This also avoids the behavior in path.join() where consecutive separators
173
+ * are collapsed. We want the cache path `a//b` to be distinct from `a/b`.
174
+ *
175
+ * @param {string[]} segments
176
+ */
177
+ static joinPath(...segments) {
178
+ let result = segments.shift() ?? "";
179
+ while (segments.length > 0) {
180
+ if (!result.endsWith("/")) {
181
+ result += "/";
182
+ }
183
+ let segment = segments.shift() ?? "";
184
+ result += segment;
185
+ }
186
+ return result;
187
+ }
188
+
189
+ // A path is considered a child path if the parent path (including a trailing
190
+ // slash) is a prefix of the child path.
191
+ isChildPath(parentPath, childPath) {
192
+ const normalized = trailingSlash.add(parentPath);
193
+ return childPath.startsWith(normalized);
194
+ }
195
+
196
+ static nextDefaultCachePath() {
197
+ const cachePath = `_object${nextPathId}`;
198
+ nextPathId++;
199
+ return cachePath;
200
+ }
201
+
202
+ runInContext(cachePath, fn) {
203
+ const context = { downstream: cachePath };
204
+ return syncStorage.run(context, fn);
205
+ }
206
+
207
+ runInContextAsync(cachePath, fn) {
208
+ const context = { downstream: cachePath };
209
+ return asyncStorage.run(context, fn);
210
+ }
211
+
212
+ /**
213
+ * Given a path for an upstream dependency, and optionally the entry for that
214
+ * path if it has already been retrieved, track the dependency between the
215
+ * upstream entry and the currently running downstream path.
216
+ *
217
+ * @param {string} upstreamPath
218
+ * @param {any} [upstreamEntry]
219
+ */
220
+ trackCurrentDependency(upstreamPath, upstreamEntry = null) {
221
+ if (!upstreamEntry) {
222
+ upstreamEntry = this.get(upstreamPath);
223
+ if (!upstreamEntry) {
224
+ // Create empty entry for this path, so that dependencies can be tracked
225
+ // for values that aren't cached.
226
+ upstreamEntry = {};
227
+ this.set(upstreamPath, upstreamEntry);
228
+ }
229
+ }
230
+
231
+ // Is this call happening downstream of another cached value?
232
+ const { downstream } =
233
+ syncStorage.getStore() ?? asyncStorage.getStore() ?? {};
234
+ if (downstream) {
235
+ if (this.isChildPath(upstreamPath, downstream)) {
236
+ // Downstream path is a child of the upstream path, no need to record
237
+ // explicit dependency
238
+ return;
239
+ }
240
+
241
+ let downstreamEntry = this.get(downstream);
242
+ if (!downstreamEntry) {
243
+ // The downstream entry has been deleted from the cache. It seems that
244
+ // Node can resurrect an asyncStorage for a run that has already
245
+ // finished. To cope, we reconstruct an entry.
246
+ downstreamEntry = {};
247
+ this.set(downstream, downstreamEntry);
248
+ }
249
+
250
+ // Add the downstream entry to the upstream entry's downstreams
251
+ upstreamEntry.downstreams ??= new Set();
252
+ upstreamEntry.downstreams.add(downstream);
253
+
254
+ // Add the upstream entry to the downstream entry's upstreams
255
+ downstreamEntry.upstreams ??= new Set();
256
+ downstreamEntry.upstreams.add(upstreamPath);
257
+ }
258
+ }
259
+ }
@@ -1,24 +1,16 @@
1
+ import { keysFromPath, Tree } from "@weborigami/async-tree";
1
2
  import * as fs from "node:fs";
2
3
  import path from "node:path";
3
4
  import Watcher from "watcher";
4
5
  import TreeEvent from "./TreeEvent.js";
5
6
 
6
- // Map of paths to trees used by watcher
7
- const pathTreeMap = new Map();
8
-
9
7
  export default function WatchFilesMixin(Base) {
10
8
  return class WatchFiles extends Base {
11
9
  addEventListener(type, listener) {
12
10
  super.addEventListener(type, listener);
13
- if (type === "change") {
14
- this.watch();
15
- }
16
11
  }
17
12
 
18
13
  onChange(filePath) {
19
- // Reset cached values.
20
- this.subfoldersMap = new Map();
21
-
22
14
  // Special case: ignore events in .git folder
23
15
  if (filePath.includes(`${path.sep}.git${path.sep}`)) {
24
16
  return;
@@ -27,38 +19,79 @@ export default function WatchFilesMixin(Base) {
27
19
  this.dispatchEvent(new TreeEvent("change", { filePath }));
28
20
  }
29
21
 
22
+ onKeysChange(key) {
23
+ super.onKeysChange?.(key);
24
+ // this.dispatchEvent(new TreeEvent("keyschange", { action, key }));
25
+ }
26
+
27
+ onValueChange(key) {
28
+ super.onValueChange?.(key);
29
+ // this.dispatchEvent(new TreeEvent("valuechange", { key }));
30
+ }
31
+
30
32
  unwatch() {
31
33
  if (!this.watching) {
32
34
  return;
33
35
  }
34
36
 
35
37
  this.watcher?.close();
36
- this.watching = false;
38
+ this.watching = null;
37
39
  }
38
40
 
39
- // Turn on watching for the directory.
41
+ // Turn on watching for the directory; resolves when the watcher is ready.
40
42
  watch() {
41
43
  if (this.watching) {
42
- return;
44
+ return this.watching;
43
45
  }
44
- this.watching = true;
45
46
 
46
47
  // Ensure the directory exists.
47
48
  fs.mkdirSync(this.dirname, { recursive: true });
48
49
 
49
50
  this.watcher = new Watcher(this.dirname, {
50
51
  ignoreInitial: true,
51
- persistent: false,
52
52
  recursive: true,
53
53
  });
54
- this.watcher.on("all", (event, filePath) => {
54
+ this.watching = new Promise((resolve) => {
55
+ this.watcher?.on("ready", resolve);
56
+ });
57
+ this.watcher.on("all", async (event, filePath) => {
55
58
  this.onChange(filePath);
59
+
60
+ const relativePath = path.relative(this.dirname, filePath);
61
+ if (relativePath.startsWith(".git")) {
62
+ return; // Ignore noisy events in .git folder
63
+ }
64
+ if (relativePath.startsWith("..")) {
65
+ // Event outside the watched directory, shouldn't happen but ignore
66
+ return;
67
+ }
68
+
69
+ const keys = keysFromPath(relativePath);
70
+ const key = keys.pop();
71
+
72
+ const target = await Tree.traverse(this, ...keys);
73
+ if (target) {
74
+ switch (event) {
75
+ case "add":
76
+ case "addDir":
77
+ target.onKeysChange(key);
78
+ break;
79
+
80
+ case "change":
81
+ target.onValueChange(key);
82
+ break;
83
+
84
+ case "unlink":
85
+ case "unlinkDir":
86
+ // Removing file/folder invalidates both its value and the keys
87
+ target.onValueChange(key);
88
+ target.onKeysChange(key);
89
+ break;
90
+ }
91
+ }
56
92
  });
57
93
 
58
- // Add to the list of FileTree instances watching this directory.
59
- const treeRefs = pathTreeMap.get(this.dirname) ?? [];
60
- treeRefs.push(new WeakRef(this));
61
- pathTreeMap.set(this.dirname, treeRefs);
94
+ return this.watching;
62
95
  }
63
96
  };
64
97
  }
@@ -0,0 +1,192 @@
1
+ import { AsyncMap, isPlainObject, SyncMap, Tree } from "@weborigami/async-tree";
2
+ import AsyncCacheTransform from "./AsyncCacheTransform.js";
3
+ import { cachePathSymbol } from "./symbols.js";
4
+ import SyncCacheTransform from "./SyncCacheTransform.js";
5
+ import systemCache from "./systemCache.js";
6
+ import SystemCacheMap from "./SystemCacheMap.js";
7
+
8
+ // For detecting async functions
9
+ const AsyncFunction = async function () {}.constructor;
10
+
11
+ /**
12
+ * Given a maplike object whose values can be cached, enable caching. This may
13
+ * apply a caching transform, and sets a cache path on the object so that it can
14
+ * use as the prefix for cache paths.
15
+ *
16
+ * Note: this typically destructively modifies the given value.
17
+ *
18
+ * @param {any} value
19
+ * @param {string} cachePath
20
+ */
21
+ export default function enableValueCaching(value, cachePath) {
22
+ if (!(typeof value === "object" || typeof value === "function")) {
23
+ // Don't need to apply caching to primitive value
24
+ return value;
25
+ }
26
+
27
+ const cacheable =
28
+ value[cachePathSymbol] === undefined && Tree.isMaplike(value);
29
+ if (cacheable) {
30
+ if (isPlainObject(value)) {
31
+ // Expression objects do their own caching
32
+ // TODO: What if it's some other kind of plain object?
33
+ markCacheable(value, cachePath);
34
+ } else if (Array.isArray(value)) {
35
+ // Cache arrays
36
+ markCacheable(value, cachePath);
37
+ } else if (value instanceof Function) {
38
+ // Cache a function
39
+ value = cacheFunction(value, cachePath);
40
+ } else if (
41
+ isTransformApplied(SyncCacheTransform, value) ||
42
+ isTransformApplied(AsyncCacheTransform, value)
43
+ ) {
44
+ // Already has caching transform applied; just mark cacheable
45
+ markCacheable(value, cachePath);
46
+ } else {
47
+ // Other maplike; convert to a Map/AsyncMap
48
+ value = Tree.from(value);
49
+ if (value instanceof Map) {
50
+ if (!(value instanceof SyncMap)) {
51
+ // Convert regular Map to SyncMap so we can extend it
52
+ value = new (SyncCacheTransform(SyncMap))(value);
53
+ } else {
54
+ // Cache a SyncMap
55
+ value = transformObject(SyncCacheTransform, value);
56
+ }
57
+ } else if (value instanceof AsyncMap) {
58
+ // Cache an AsyncMap
59
+ value = transformObject(AsyncCacheTransform, value);
60
+ }
61
+ markCacheable(value, cachePath);
62
+ }
63
+ }
64
+
65
+ return value;
66
+ }
67
+
68
+ /**
69
+ * Cache a function with arity 1 or greater that takes string arguments
70
+ *
71
+ * @param {Function} fn
72
+ * @param {string} cachePath
73
+ */
74
+ export function cacheFunction(fn, cachePath) {
75
+ if (fn.length === 0) {
76
+ // Return as is
77
+ return fn;
78
+ }
79
+ let result;
80
+ if (fn instanceof AsyncFunction) {
81
+ // Return an async function that caches results for a unary argument
82
+ result = async (...args) => {
83
+ if (!allStringArguments(args)) {
84
+ // Run function in context of this cache path, but don't cache result
85
+ return systemCache.runInContextAsync(cachePath, () => fn(...args));
86
+ }
87
+ const keyCachePath = SystemCacheMap.joinPath(cachePath, args.join("/"));
88
+ let result = systemCache.getOrInsertComputedAsync(
89
+ keyCachePath,
90
+ async () => fn(...args),
91
+ );
92
+ result = enableValueCaching(result, keyCachePath);
93
+ return result;
94
+ };
95
+ } else {
96
+ // Return a sync function that caches results for a unary argument
97
+ result = (...args) => {
98
+ if (!allStringArguments(args)) {
99
+ // Run function in context of this cache path, but don't cache result
100
+ return systemCache.runInContext(cachePath, () => fn(...args));
101
+ }
102
+ const keyCachePath = SystemCacheMap.joinPath(cachePath, args.join("/"));
103
+ let result = systemCache.getOrInsertComputed(keyCachePath, () =>
104
+ fn(...args),
105
+ );
106
+ result = enableValueCaching(result, keyCachePath);
107
+ return result;
108
+ };
109
+ }
110
+ Object.defineProperty(result, "length", {
111
+ value: fn.length,
112
+ configurable: true,
113
+ });
114
+ markCacheable(result, cachePath);
115
+ return result;
116
+ }
117
+
118
+ // Non-string keys and non-empty strings can't be cached
119
+ function allStringArguments(args) {
120
+ return (
121
+ args.length > 0 &&
122
+ args.every((arg) => typeof arg === "string" && arg.length > 0)
123
+ );
124
+ }
125
+
126
+ export function isTransformApplied(Transform, obj) {
127
+ let transformName = Transform.name;
128
+ if (!transformName) {
129
+ throw `isTransformApplied was called on an unnamed transform function, but a name is required.`;
130
+ }
131
+ if (transformName.endsWith("Transform")) {
132
+ transformName = transformName.slice(0, -9);
133
+ }
134
+ // Walk up prototype chain looking for a constructor with the same name as the
135
+ // transform. This is not a great test.
136
+ for (let proto = obj; proto; proto = Object.getPrototypeOf(proto)) {
137
+ if (proto.constructor.name === transformName) {
138
+ return true;
139
+ }
140
+ }
141
+ return false;
142
+ }
143
+
144
+ function markCacheable(object, cachePath) {
145
+ Object.defineProperty(object, cachePathSymbol, {
146
+ configurable: true,
147
+ enumerable: false,
148
+ value: cachePath,
149
+ });
150
+ }
151
+ /**
152
+ * Apply a functional class mixin to an individual object instance.
153
+ *
154
+ * This works by create an intermediate class, creating an instance of that, and
155
+ * then setting the intermediate class's prototype to the given individual
156
+ * object. The resulting, extended object is then returned.
157
+ *
158
+ * This manipulation of the prototype chain is generally sound in JavaScript,
159
+ * with some caveats. In particular, the original object class cannot make
160
+ * direct use of private members; JavaScript will complain if the extended
161
+ * object does anything that requires access to those private members.
162
+ *
163
+ * @param {Function} Transform
164
+ * @param {any} obj
165
+ */
166
+ export function transformObject(Transform, obj) {
167
+ // Apply the mixin to Object and instantiate that. The Object base class here
168
+ // is going to be cut out of the prototype chain in a moment; we just use
169
+ // Object as a convenience because its constructor takes no arguments.
170
+ const mixed = new (Transform(Object))();
171
+
172
+ // Find the highest prototype in the chain that was added by the class mixin.
173
+ // The mixin may have added multiple prototypes to the chain. Walk up the
174
+ // prototype chain until we hit Object.
175
+ let mixinProto = Object.getPrototypeOf(mixed);
176
+ while (Object.getPrototypeOf(mixinProto) !== Object.prototype) {
177
+ mixinProto = Object.getPrototypeOf(mixinProto);
178
+ }
179
+
180
+ // Redirect the prototype chain above the mixin to point to the original
181
+ // object. The mixed object now extends the original object with the mixin.
182
+ Object.setPrototypeOf(mixinProto, obj);
183
+
184
+ // Create a new constructor for this mixed object that reflects its prototype
185
+ // chain. Because we've already got the instance we want, we won't use this
186
+ // constructor now, but this can be used later to instantiate other objects
187
+ // that look like the mixed one.
188
+ mixed.constructor = Transform(obj.constructor);
189
+
190
+ // Return the mixed object.
191
+ return mixed;
192
+ }