wardens 0.5.1 → 0.6.0-rc.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,236 @@
1
+ import type ResourceControls from '../resource-controls';
2
+ import { create, destroy } from '../root-lifecycle';
3
+ import bindContext from '../bind-context';
4
+
5
+ describe('roots', () => {
6
+ describe('create', () => {
7
+ it('allocates the resource', async () => {
8
+ const config = { test: 'init-args' };
9
+ const Test = vi.fn(
10
+ async (_resource: ResourceControls, config: { test: string }) => ({
11
+ value: config,
12
+ }),
13
+ );
14
+
15
+ await expect(create(Test, config)).resolves.toEqual(config);
16
+ expect(Test).toHaveBeenCalledWith(expect.anything(), config);
17
+ });
18
+
19
+ describe('after initialization failure', () => {
20
+ it('destroys child resources in reverse order', async () => {
21
+ const spy = vi.fn();
22
+ const First = async () => ({ value: [], destroy: () => spy('1st') });
23
+ const Second = async () => ({ value: [], destroy: () => spy('2nd') });
24
+
25
+ const Parent = async (resource: ResourceControls) => {
26
+ await resource.create(First);
27
+ await resource.create(Second);
28
+ throw new Error('Testing resource initialization errors');
29
+ };
30
+
31
+ await expect(create(Parent)).rejects.toThrow();
32
+ expect(spy.mock.calls).toEqual([['2nd'], ['1st']]);
33
+ });
34
+
35
+ it('continues even if the children cannot be destroyed', async () => {
36
+ const parentError = new Error('Testing parent resource aborts');
37
+ const childError = new Error('Testing child resource aborts');
38
+ const Child = async () => ({
39
+ value: [],
40
+ destroy() {
41
+ throw childError;
42
+ },
43
+ });
44
+
45
+ const Parent = async (resource: ResourceControls) => {
46
+ await resource.create(Child);
47
+ await resource.create(Child);
48
+ throw parentError;
49
+ };
50
+
51
+ await expect(create(Parent)).rejects.toMatchObject({
52
+ cause: parentError,
53
+ failures: [childError, childError],
54
+ });
55
+ });
56
+ });
57
+ });
58
+
59
+ describe('destroy', () => {
60
+ it('deallocates the resource', async () => {
61
+ const spy = vi.fn<[], void>();
62
+ const Test = async () => ({
63
+ value: {},
64
+ destroy: spy,
65
+ });
66
+
67
+ const test = await create(Test);
68
+
69
+ expect(spy).not.toHaveBeenCalled();
70
+ await expect(destroy(test)).resolves.not.toThrow();
71
+ expect(spy).toHaveBeenCalled();
72
+ });
73
+
74
+ it('throws an error if the object is not a resource', async () => {
75
+ await expect(destroy({})).rejects.toThrow(
76
+ 'Cannot destroy object. It is not a resource.',
77
+ );
78
+ });
79
+
80
+ it('survives if the resource is already deallocated', async () => {
81
+ const Test = async () => ({ value: [] });
82
+
83
+ const test = await create(Test);
84
+ await destroy(test);
85
+
86
+ await expect(destroy(test)).resolves.not.toThrow();
87
+ });
88
+
89
+ it('automatically unmounts all children', async () => {
90
+ const spy = vi.fn();
91
+ const Child = async () => ({ value: [], destroy: spy });
92
+ async function Parent(resource: ResourceControls) {
93
+ await resource.create(Child);
94
+ return { value: [] };
95
+ }
96
+
97
+ const parent = await create(Parent);
98
+ await destroy(parent);
99
+
100
+ expect(spy).toHaveBeenCalled();
101
+ });
102
+
103
+ it('throws an error if any of the children fail to close', async () => {
104
+ const error = new Error('Testing resource destruction errors');
105
+ const Child = async () => ({
106
+ value: [],
107
+ destroy() {
108
+ throw error;
109
+ },
110
+ });
111
+
112
+ const Parent = async (resource: ResourceControls) => {
113
+ await resource.create(Child);
114
+ return { value: [] };
115
+ };
116
+
117
+ const parent = await create(Parent);
118
+ await expect(destroy(parent)).rejects.toThrow(error);
119
+ });
120
+
121
+ it('destroys child resources in reverse order', async () => {
122
+ const spy = vi.fn();
123
+ const First = async () => ({ value: [], destroy: () => spy('1st') });
124
+ const Second = async () => ({ value: [], destroy: () => spy('2nd') });
125
+
126
+ const Parent = async (resource: ResourceControls) => {
127
+ await resource.create(First);
128
+ await resource.create(Second);
129
+ return { value: [] };
130
+ };
131
+
132
+ const parent = await create(Parent);
133
+ await destroy(parent);
134
+
135
+ expect(spy.mock.calls).toEqual([['2nd'], ['1st']]);
136
+ });
137
+
138
+ it('reports if multiple resources could not be destroyed', async () => {
139
+ const childError = new Error('Testing child resource aborts');
140
+ const Child = async () => ({
141
+ value: [],
142
+ destroy() {
143
+ throw childError;
144
+ },
145
+ });
146
+
147
+ const Parent = async (resource: ResourceControls) => {
148
+ await resource.create(Child);
149
+ await resource.create(Child);
150
+ return { value: [] };
151
+ };
152
+
153
+ const parent = await create(Parent);
154
+ await expect(destroy(parent)).rejects.toMatchObject({
155
+ failures: [childError, childError],
156
+ });
157
+ });
158
+
159
+ it('ensures child resources outlive their consumers', async () => {
160
+ const Child = async () => ({ value: [1] });
161
+ const Parent = async (resource: ResourceControls) => {
162
+ const child = await resource.create(Child);
163
+ return {
164
+ value: [],
165
+ destroy() {
166
+ // The `child` resource must still be usable here.
167
+ expect(child).toHaveLength(1);
168
+ },
169
+ };
170
+ };
171
+
172
+ const parent = await create(Parent);
173
+ await destroy(parent);
174
+ });
175
+
176
+ it('destroys child resources even if the parent fails to close', async () => {
177
+ const spy = vi.fn();
178
+ const Child = async () => ({ value: [], destroy: spy });
179
+ const Parent = async (resource: ResourceControls) => {
180
+ await resource.create(Child);
181
+ return {
182
+ value: [],
183
+ destroy() {
184
+ throw new Error('Testing parent teardown errors');
185
+ },
186
+ };
187
+ };
188
+
189
+ const parent = await create(Parent);
190
+ await expect(destroy(parent)).rejects.toThrow();
191
+ expect(spy).toHaveBeenCalled();
192
+ });
193
+
194
+ it('aggregates errors if the child resource fails to close', async () => {
195
+ const parentError = new Error('Testing parent resource aborts');
196
+ const childError = new Error('Testing child resource aborts');
197
+
198
+ const Child = async () => ({
199
+ value: [],
200
+ destroy() {
201
+ throw childError;
202
+ },
203
+ });
204
+
205
+ const Parent = async (resource: ResourceControls) => {
206
+ await resource.create(Child);
207
+ return {
208
+ value: [],
209
+ destroy() {
210
+ throw parentError;
211
+ },
212
+ };
213
+ };
214
+
215
+ const parent = await create(Parent);
216
+ await expect(destroy(parent)).rejects.toMatchObject({
217
+ failures: [parentError, childError],
218
+ });
219
+ });
220
+
221
+ it('guards against creating new resources after teardown', async () => {
222
+ const Child = async () => ({ value: [] });
223
+ const Parent = async (resource: ResourceControls) => ({
224
+ value: bindContext(resource),
225
+ });
226
+
227
+ const parent = await create(Parent);
228
+ const { create: resourceCreate } = parent;
229
+ await destroy(parent);
230
+
231
+ await expect(resourceCreate(Child)).rejects.toThrow(
232
+ /cannot create.*after teardown/i,
233
+ );
234
+ });
235
+ });
236
+ });
@@ -0,0 +1,236 @@
1
+ import type ResourceControls from '../resource-controls';
2
+ import { create, destroy } from '../root-lifecycle';
3
+ import bindContext from '../bind-context';
4
+
5
+ describe('roots', () => {
6
+ describe('create', () => {
7
+ it('allocates the resource', async () => {
8
+ const config = { test: 'init-args' };
9
+ const Test = vi.fn(
10
+ async (_resource: ResourceControls, config: { test: string }) => ({
11
+ value: config,
12
+ }),
13
+ );
14
+
15
+ await expect(create(Test, config)).resolves.toEqual(config);
16
+ expect(Test).toHaveBeenCalledWith(expect.anything(), config);
17
+ });
18
+
19
+ describe('after initialization failure', () => {
20
+ it('destroys child resources in reverse order', async () => {
21
+ const spy = vi.fn();
22
+ const First = async () => ({ value: [], destroy: () => spy('1st') });
23
+ const Second = async () => ({ value: [], destroy: () => spy('2nd') });
24
+
25
+ const Parent = async (resource: ResourceControls) => {
26
+ await resource.create(First);
27
+ await resource.create(Second);
28
+ throw new Error('Testing resource initialization errors');
29
+ };
30
+
31
+ await expect(create(Parent)).rejects.toThrow();
32
+ expect(spy.mock.calls).toEqual([['2nd'], ['1st']]);
33
+ });
34
+
35
+ it('continues even if the children cannot be destroyed', async () => {
36
+ const parentError = new Error('Testing parent resource aborts');
37
+ const childError = new Error('Testing child resource aborts');
38
+ const Child = async () => ({
39
+ value: [],
40
+ destroy() {
41
+ throw childError;
42
+ },
43
+ });
44
+
45
+ const Parent = async (resource: ResourceControls) => {
46
+ await resource.create(Child);
47
+ await resource.create(Child);
48
+ throw parentError;
49
+ };
50
+
51
+ await expect(create(Parent)).rejects.toMatchObject({
52
+ cause: parentError,
53
+ failures: [childError, childError],
54
+ });
55
+ });
56
+ });
57
+ });
58
+
59
+ describe('destroy', () => {
60
+ it('deallocates the resource', async () => {
61
+ const spy = vi.fn<[], void>();
62
+ const Test = async () => ({
63
+ value: {},
64
+ destroy: spy,
65
+ });
66
+
67
+ const test = await create(Test);
68
+
69
+ expect(spy).not.toHaveBeenCalled();
70
+ await expect(destroy(test)).resolves.not.toThrow();
71
+ expect(spy).toHaveBeenCalled();
72
+ });
73
+
74
+ it('throws an error if the object is not a resource', async () => {
75
+ await expect(destroy({})).rejects.toThrow(
76
+ 'Cannot destroy object. It is not a resource.',
77
+ );
78
+ });
79
+
80
+ it('survives if the resource is already deallocated', async () => {
81
+ const Test = async () => ({ value: [] });
82
+
83
+ const test = await create(Test);
84
+ await destroy(test);
85
+
86
+ await expect(destroy(test)).resolves.not.toThrow();
87
+ });
88
+
89
+ it('automatically unmounts all children', async () => {
90
+ const spy = vi.fn();
91
+ const Child = async () => ({ value: [], destroy: spy });
92
+ async function Parent(resource: ResourceControls) {
93
+ await resource.create(Child);
94
+ return { value: [] };
95
+ }
96
+
97
+ const parent = await create(Parent);
98
+ await destroy(parent);
99
+
100
+ expect(spy).toHaveBeenCalled();
101
+ });
102
+
103
+ it('throws an error if any of the children fail to close', async () => {
104
+ const error = new Error('Testing resource destruction errors');
105
+ const Child = async () => ({
106
+ value: [],
107
+ destroy() {
108
+ throw error;
109
+ },
110
+ });
111
+
112
+ const Parent = async (resource: ResourceControls) => {
113
+ await resource.create(Child);
114
+ return { value: [] };
115
+ };
116
+
117
+ const parent = await create(Parent);
118
+ await expect(destroy(parent)).rejects.toThrow(error);
119
+ });
120
+
121
+ it('destroys child resources in reverse order', async () => {
122
+ const spy = vi.fn();
123
+ const First = async () => ({ value: [], destroy: () => spy('1st') });
124
+ const Second = async () => ({ value: [], destroy: () => spy('2nd') });
125
+
126
+ const Parent = async (resource: ResourceControls) => {
127
+ await resource.create(First);
128
+ await resource.create(Second);
129
+ return { value: [] };
130
+ };
131
+
132
+ const parent = await create(Parent);
133
+ await destroy(parent);
134
+
135
+ expect(spy.mock.calls).toEqual([['2nd'], ['1st']]);
136
+ });
137
+
138
+ it('reports if multiple resources could not be destroyed', async () => {
139
+ const childError = new Error('Testing child resource aborts');
140
+ const Child = async () => ({
141
+ value: [],
142
+ destroy() {
143
+ throw childError;
144
+ },
145
+ });
146
+
147
+ const Parent = async (resource: ResourceControls) => {
148
+ await resource.create(Child);
149
+ await resource.create(Child);
150
+ return { value: [] };
151
+ };
152
+
153
+ const parent = await create(Parent);
154
+ await expect(destroy(parent)).rejects.toMatchObject({
155
+ failures: [childError, childError],
156
+ });
157
+ });
158
+
159
+ it('ensures child resources outlive their consumers', async () => {
160
+ const Child = async () => ({ value: [1] });
161
+ const Parent = async (resource: ResourceControls) => {
162
+ const child = await resource.create(Child);
163
+ return {
164
+ value: [],
165
+ destroy() {
166
+ // The `child` resource must still be usable here.
167
+ expect(child).toHaveLength(1);
168
+ },
169
+ };
170
+ };
171
+
172
+ const parent = await create(Parent);
173
+ await destroy(parent);
174
+ });
175
+
176
+ it('destroys child resources even if the parent fails to close', async () => {
177
+ const spy = vi.fn();
178
+ const Child = async () => ({ value: [], destroy: spy });
179
+ const Parent = async (resource: ResourceControls) => {
180
+ await resource.create(Child);
181
+ return {
182
+ value: [],
183
+ destroy() {
184
+ throw new Error('Testing parent teardown errors');
185
+ },
186
+ };
187
+ };
188
+
189
+ const parent = await create(Parent);
190
+ await expect(destroy(parent)).rejects.toThrow();
191
+ expect(spy).toHaveBeenCalled();
192
+ });
193
+
194
+ it('aggregates errors if the child resource fails to close', async () => {
195
+ const parentError = new Error('Testing parent resource aborts');
196
+ const childError = new Error('Testing child resource aborts');
197
+
198
+ const Child = async () => ({
199
+ value: [],
200
+ destroy() {
201
+ throw childError;
202
+ },
203
+ });
204
+
205
+ const Parent = async (resource: ResourceControls) => {
206
+ await resource.create(Child);
207
+ return {
208
+ value: [],
209
+ destroy() {
210
+ throw parentError;
211
+ },
212
+ };
213
+ };
214
+
215
+ const parent = await create(Parent);
216
+ await expect(destroy(parent)).rejects.toMatchObject({
217
+ failures: [parentError, childError],
218
+ });
219
+ });
220
+
221
+ it('guards against creating new resources after teardown', async () => {
222
+ const Child = async () => ({ value: [] });
223
+ const Parent = async (resource: ResourceControls) => ({
224
+ value: bindContext(resource),
225
+ });
226
+
227
+ const parent = await create(Parent);
228
+ const { create: resourceCreate } = parent;
229
+ await destroy(parent);
230
+
231
+ await expect(resourceCreate(Child)).rejects.toThrow(
232
+ /cannot create.*after teardown/i,
233
+ );
234
+ });
235
+ });
236
+ });
@@ -1,4 +1,4 @@
1
- import { create, ResourceContext, ResourceHandle } from '../';
1
+ import { create, ResourceControls, ResourceHandle } from '../';
2
2
 
3
3
  describe('Utility types', () => {
4
4
  describe('ResourceHandle', () => {
@@ -17,7 +17,7 @@ describe('Utility types', () => {
17
17
  });
18
18
 
19
19
  it('infers the type when the value comes from a parameter', async () => {
20
- async function Test(_ctx: ResourceContext, value: { count: number }) {
20
+ async function Test(_ctx: ResourceControls, value: { count: number }) {
21
21
  return {
22
22
  value,
23
23
  };
@@ -1,4 +1,14 @@
1
- import type { Resource } from './types';
1
+ import type { Resource } from './utility-types';
2
+
3
+ /** Maps an external API back to the resource that created it. */
4
+ export const resources = new WeakMap<object, RevokableResource>();
5
+
6
+ /**
7
+ * Identifies if an object is a Wardens resource. Carries no metadata and
8
+ * objects are never deleted. This is separate from `resources` to avoid
9
+ * retaining associated objects in memory.
10
+ */
11
+ export const constructed = new WeakSet<object>();
2
12
 
3
13
  export interface RevokableResource {
4
14
  resource: Resource<object>;
@@ -18,13 +28,3 @@ export interface RevokableResource {
18
28
  */
19
29
  curfew: { enforced: boolean };
20
30
  }
21
-
22
- /** Maps an external API back to the resource that created it. */
23
- export const resources = new WeakMap<object, RevokableResource>();
24
-
25
- /**
26
- * Identifies if an object is a Wardens resource. Carries no metadata and
27
- * objects are never deleted. This is separate from `resources` to avoid
28
- * retaining associated objects in memory.
29
- */
30
- export const constructed = new WeakSet<object>();
package/src/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export { default as bindContext } from './bind-context';
2
- export { create, destroy } from './allocation';
3
- export type { Resource, ResourceHandle } from './types';
4
- export type { default as ResourceContext } from './resource-context';
2
+ export { create, destroy } from './root-lifecycle';
3
+ export { createContext } from './inherited-context';
4
+ export type { Resource, ResourceHandle } from './utility-types';
5
+ export type { default as ResourceControls } from './resource-controls';
@@ -0,0 +1,32 @@
1
+ /**
2
+ * A prototype chain that matches the resource hierarchy. This is used to
3
+ * pass state down the tree without plumbing through every function.
4
+ */
5
+ export type InheritedContext = Record<symbol, unknown>;
6
+
7
+ /** An opaque handle used to get/set values in a prototype chain. */
8
+ export class ContextHandle<Value> {
9
+ static getId(handle: ContextHandle<unknown>): symbol {
10
+ return handle.#id;
11
+ }
12
+
13
+ static getDefaultValue<Value>(handle: ContextHandle<Value>): Value {
14
+ return handle.#getDefaultContext();
15
+ }
16
+
17
+ #id = Symbol('Context ID');
18
+ #getDefaultContext: () => Value;
19
+
20
+ constructor(getDefaultContext: () => Value) {
21
+ this.#getDefaultContext = getDefaultContext;
22
+ }
23
+ }
24
+
25
+ /**
26
+ * Create a context object that is used to get and set context values in the
27
+ * resource hierarchy.
28
+ */
29
+ export const createContext = <Value>(
30
+ /** Returns a default value if the context could not be found. */
31
+ getDefaultContext: () => Value,
32
+ ): ContextHandle<Value> => new ContextHandle(getDefaultContext);
@@ -1,23 +1,27 @@
1
- import { create, destroy } from './allocation';
2
- import { RevokableResource } from './state';
3
- import { ResourceFactory, ParametrizedResourceFactory } from './types';
1
+ import { createWithContext, destroy } from './resource-lifecycle';
2
+ import { ContextHandle, InheritedContext } from './inherited-context';
3
+ import { RevokableResource } from './global-weakrefs';
4
+ import { ResourceFactory, ParametrizedResourceFactory } from './utility-types';
4
5
 
5
6
  /**
6
7
  * An instance of this class is passed to resources as they're being
7
8
  * provisioned. It allows them to provision other resources while keeping
8
9
  * track of ownership and lifetimes.
9
10
  */
10
- export default class ResourceContext {
11
+ export default class ResourceControls {
11
12
  #destroyed = new WeakSet<object>();
12
13
  #resources: Set<object>;
13
- #freeze: RevokableResource['curfew'];
14
+ #curfew: RevokableResource['curfew'];
15
+ #state: InheritedContext;
14
16
 
15
17
  constructor(
18
+ state: InheritedContext,
16
19
  ownedResources: Set<object>,
17
20
  freeze: RevokableResource['curfew'],
18
21
  ) {
22
+ this.#state = state;
19
23
  this.#resources = ownedResources;
20
- this.#freeze = freeze;
24
+ this.#curfew = freeze;
21
25
  }
22
26
 
23
27
  /** Provision an owned resource and make sure it doesn't outlive us. */
@@ -27,11 +31,12 @@ export default class ResourceContext {
27
31
  | ResourceFactory<Controls>,
28
32
  ...args: Args
29
33
  ): Promise<Controls> => {
30
- if (this.#freeze.enforced) {
34
+ if (this.#curfew.enforced) {
31
35
  throw new Error('Cannot create new resources after teardown.');
32
36
  }
33
37
 
34
- const controls = await create(factory, ...args);
38
+ const context = Object.create(this.#state);
39
+ const controls = await createWithContext(context, factory, ...args);
35
40
  this.#resources.add(controls);
36
41
 
37
42
  return controls;
@@ -54,4 +59,23 @@ export default class ResourceContext {
54
59
  this.#destroyed.add(resource);
55
60
  await destroy(resource);
56
61
  };
62
+
63
+ /** Store a value in context. Anything down the chain can read it. */
64
+ public setContext = <Value>(
65
+ context: ContextHandle<Value>,
66
+ value: Value,
67
+ ): void => {
68
+ this.#state[ContextHandle.getId(context)] = value;
69
+ };
70
+
71
+ /** Retrieve a value from context, or a default if it is unset. */
72
+ public getContext = <Value>(context: ContextHandle<Value>): Value => {
73
+ const id = ContextHandle.getId(context);
74
+
75
+ if (id in this.#state) {
76
+ return this.#state[id] as Value;
77
+ }
78
+
79
+ return ContextHandle.getDefaultValue(context);
80
+ };
57
81
  }
@@ -1,25 +1,27 @@
1
- import { resources, constructed } from './state';
2
- import wrap from './proxy';
3
- import ResourceContext from './resource-context';
4
- import { ResourceFactory, ParametrizedResourceFactory } from './types';
1
+ import { resources, constructed } from './global-weakrefs';
2
+ import wrap from './wrap-with-proxy';
3
+ import ResourceControls from './resource-controls';
4
+ import type { InheritedContext } from './inherited-context';
5
+ import { ResourceFactory, ParametrizedResourceFactory } from './utility-types';
5
6
 
6
7
  /** Provision a resource and return its external API. */
7
- export const create = async <
8
+ export const createWithContext = async <
8
9
  Controls extends object,
9
10
  Args extends Array<unknown>,
10
11
  >(
11
- provision:
12
+ state: InheritedContext,
13
+ factory:
12
14
  | ParametrizedResourceFactory<Controls, Args>
13
15
  | ResourceFactory<Controls>,
14
16
  ...args: Args
15
17
  ): Promise<Controls> => {
16
18
  const curfew = { enforced: false };
17
19
  const children: Set<object> = new Set();
18
- const context = new ResourceContext(children, curfew);
19
- let resource: Awaited<ReturnType<typeof provision>>;
20
+ const context = new ResourceControls(state, children, curfew);
21
+ let resource: Awaited<ReturnType<typeof factory>>;
20
22
 
21
23
  try {
22
- resource = await provision(context, ...args);
24
+ resource = await factory(context, ...args);
23
25
  } catch (error) {
24
26
  // Resource could not be created. Clean up the intermediate resources.
25
27
  const orphans = Array.from(children).reverse();
@@ -0,0 +1,22 @@
1
+ import { createWithContext } from './resource-lifecycle';
2
+ import { ParametrizedResourceFactory } from './utility-types';
3
+ import { ResourceFactory } from './utility-types';
4
+
5
+ /** Provision a resource and return its external API. */
6
+ export const create = async <
7
+ Controls extends object,
8
+ Args extends Array<unknown>,
9
+ >(
10
+ /** An async function that resolves to a resource. */
11
+ factory:
12
+ | ParametrizedResourceFactory<Controls, Args>
13
+ | ResourceFactory<Controls>,
14
+ ...args: Args
15
+ ): Promise<Controls> => {
16
+ const rootContext = Object.create(null);
17
+ return createWithContext(rootContext, factory, ...args);
18
+ };
19
+
20
+ // Destroying a root resource is the same process as destroying a child
21
+ // resource. No need to change the implementation.
22
+ export { destroy } from './resource-lifecycle';