wardens 0.4.1 → 0.5.0-rc.0

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.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,21 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ### Changed
10
+
11
+ - Wardens is now published with ESM (`type=module`). It should be backwards compatible.
12
+ - Now `destroy(...)` throws if you pass an object that wasn't constructed with `create(...)`.
13
+
14
+ ### Fixed
15
+
16
+ - If a resource fails while initializing, now all intermediate child resources are destroyed as well.
17
+ - If a resource fails while being destroyed, now its child resources are destroyed as well.
18
+ - Resources can no longer provision child resources after teardown. This closes a loophole where resources could escape destruction.
19
+
20
+ ### Added
21
+
22
+ - New `ResourceHandle<T>` utility type represents the value returned when creating a resource.
23
+
9
24
  ## [0.4.1] - 2023-01-14
10
25
 
11
26
  ### Fixed
@@ -0,0 +1 @@
1
+ "use strict";var E=Object.defineProperty;var C=(t,e,r)=>e in t?E(t,e,{enumerable:!0,configurable:!0,writable:!0,value:r}):t[e]=r;var h=(t,e,r)=>(C(t,typeof e!="symbol"?e+"":e,r),r),g=(t,e,r)=>{if(!e.has(t))throw TypeError("Cannot "+r)};var a=(t,e,r)=>(g(t,e,"read from private field"),r?r.call(t):e.get(t)),f=(t,e,r)=>{if(e.has(t))throw TypeError("Cannot add the same private member more than once");e instanceof WeakSet?e.add(t):e.set(t,r)},y=(t,e,r,o)=>(g(t,e,"write to private field"),o?o.call(t,r):e.set(t,r),r);Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});function W(t){const e=new WeakMap;return new Proxy(t,{get(r,o){const s=Reflect.get(r,o,r);if(typeof s=="function"){if(e.has(s)===!1){const n=s.bind(r);Object.defineProperties(n,Object.getOwnPropertyDescriptors(s)),e.set(s,n)}return e.get(s)}return s},set(r,o,s){return Reflect.set(r,o,s,r)}})}const p=new WeakMap,S=new WeakSet;function O(t){return Proxy.revocable(t,{})}var l,i,u;class R{constructor(e,r){f(this,l,new WeakSet);f(this,i,void 0);f(this,u,void 0);h(this,"create",async(e,...r)=>{if(a(this,u).enforced)throw new Error("Cannot create new resources after teardown.");const o=await x(e,...r);return a(this,i).add(o),o});h(this,"destroy",async e=>{if(a(this,l).has(e))throw new Error("Resource already destroyed.");if(!a(this,i).has(e))throw new Error("You do not own this resource.");a(this,i).delete(e),a(this,l).add(e),await m(e)});y(this,i,e),y(this,u,r)}}l=new WeakMap,i=new WeakMap,u=new WeakMap;const x=async(t,...e)=>{const r={enforced:!1},o=new Set,s=new R(o,r);let n;try{n=await t(s,...e)}catch(v){const k=Array.from(o).reverse(),b=(await Promise.allSettled(k.map(d=>s.destroy(d)))).filter(d=>d.status==="rejected");throw b.length?P(b.map(d=>d.reason),{cause:v}):v}const c=n.value,{proxy:w,revoke:j}=O(c);return S.add(w),p.set(w,{curfew:r,resource:n,children:o,revoke:j}),w},m=async t=>{if(!S.has(t))throw new Error("Cannot destroy object. It is not a resource.");const e=p.get(t);if(e){p.delete(t),e.revoke();let r={status:"fulfilled",value:void 0};if(e.resource.destroy)try{await e.resource.destroy()}catch(c){r={status:"rejected",reason:c}}e.curfew.enforced=!0;const o=Array.from(e.children).reverse().map(m),s=await Promise.allSettled(o),n=[r].concat(s).filter(c=>c.status==="rejected");if(n.length)throw P(n.map(c=>c.reason))}},P=(t,e)=>t.length===1?t[0]:new B(t,e);class B extends Error{constructor(e,r){super("Some resources could not be destroyed. See the `failures` property for details.",r),this.failures=e}}exports.bindContext=W;exports.create=x;exports.destroy=m;
@@ -0,0 +1,125 @@
1
+ var P = Object.defineProperty;
2
+ var j = (t, e, r) => e in t ? P(t, e, { enumerable: !0, configurable: !0, writable: !0, value: r }) : t[e] = r;
3
+ var h = (t, e, r) => (j(t, typeof e != "symbol" ? e + "" : e, r), r), x = (t, e, r) => {
4
+ if (!e.has(t))
5
+ throw TypeError("Cannot " + r);
6
+ };
7
+ var a = (t, e, r) => (x(t, e, "read from private field"), r ? r.call(t) : e.get(t)), f = (t, e, r) => {
8
+ if (e.has(t))
9
+ throw TypeError("Cannot add the same private member more than once");
10
+ e instanceof WeakSet ? e.add(t) : e.set(t, r);
11
+ }, y = (t, e, r, o) => (x(t, e, "write to private field"), o ? o.call(t, r) : e.set(t, r), r);
12
+ function A(t) {
13
+ const e = /* @__PURE__ */ new WeakMap();
14
+ return new Proxy(t, {
15
+ get(r, o) {
16
+ const s = Reflect.get(r, o, r);
17
+ if (typeof s == "function") {
18
+ if (e.has(s) === !1) {
19
+ const n = s.bind(r);
20
+ Object.defineProperties(
21
+ n,
22
+ Object.getOwnPropertyDescriptors(s)
23
+ ), e.set(s, n);
24
+ }
25
+ return e.get(s);
26
+ }
27
+ return s;
28
+ },
29
+ set(r, o, s) {
30
+ return Reflect.set(r, o, s, r);
31
+ }
32
+ });
33
+ }
34
+ const p = /* @__PURE__ */ new WeakMap(), g = /* @__PURE__ */ new WeakSet();
35
+ function W(t) {
36
+ return Proxy.revocable(t, {});
37
+ }
38
+ var l, i, u;
39
+ class C {
40
+ constructor(e, r) {
41
+ f(this, l, /* @__PURE__ */ new WeakSet());
42
+ f(this, i, void 0);
43
+ f(this, u, void 0);
44
+ /** Provision an owned resource and make sure it doesn't outlive us. */
45
+ h(this, "create", async (e, ...r) => {
46
+ if (a(this, u).enforced)
47
+ throw new Error("Cannot create new resources after teardown.");
48
+ const o = await R(e, ...r);
49
+ return a(this, i).add(o), o;
50
+ });
51
+ /**
52
+ * Tear down a resource. Happens automatically when resource owners are
53
+ * deallocated.
54
+ */
55
+ h(this, "destroy", async (e) => {
56
+ if (a(this, l).has(e))
57
+ throw new Error("Resource already destroyed.");
58
+ if (!a(this, i).has(e))
59
+ throw new Error("You do not own this resource.");
60
+ a(this, i).delete(e), a(this, l).add(e), await S(e);
61
+ });
62
+ y(this, i, e), y(this, u, r);
63
+ }
64
+ }
65
+ l = new WeakMap(), i = new WeakMap(), u = new WeakMap();
66
+ const R = async (t, ...e) => {
67
+ const r = { enforced: !1 }, o = /* @__PURE__ */ new Set(), s = new C(o, r);
68
+ let n;
69
+ try {
70
+ n = await t(s, ...e);
71
+ } catch (m) {
72
+ const E = Array.from(o).reverse(), v = (await Promise.allSettled(
73
+ E.map((d) => s.destroy(d))
74
+ )).filter(
75
+ (d) => d.status === "rejected"
76
+ );
77
+ throw v.length ? b(
78
+ v.map((d) => d.reason),
79
+ { cause: m }
80
+ ) : m;
81
+ }
82
+ const c = n.value, { proxy: w, revoke: k } = W(c);
83
+ return g.add(w), p.set(w, {
84
+ curfew: r,
85
+ resource: n,
86
+ children: o,
87
+ revoke: k
88
+ }), w;
89
+ }, S = async (t) => {
90
+ if (!g.has(t))
91
+ throw new Error("Cannot destroy object. It is not a resource.");
92
+ const e = p.get(t);
93
+ if (e) {
94
+ p.delete(t), e.revoke();
95
+ let r = {
96
+ status: "fulfilled",
97
+ value: void 0
98
+ };
99
+ if (e.resource.destroy)
100
+ try {
101
+ await e.resource.destroy();
102
+ } catch (c) {
103
+ r = { status: "rejected", reason: c };
104
+ }
105
+ e.curfew.enforced = !0;
106
+ const o = Array.from(e.children).reverse().map(S), s = await Promise.allSettled(o), n = [r].concat(s).filter(
107
+ (c) => c.status === "rejected"
108
+ );
109
+ if (n.length)
110
+ throw b(n.map((c) => c.reason));
111
+ }
112
+ }, b = (t, e) => t.length === 1 ? t[0] : new B(t, e);
113
+ class B extends Error {
114
+ constructor(e, r) {
115
+ super(
116
+ "Some resources could not be destroyed. See the `failures` property for details.",
117
+ r
118
+ ), this.failures = e;
119
+ }
120
+ }
121
+ export {
122
+ A as bindContext,
123
+ R as create,
124
+ S as destroy
125
+ };
package/package.json CHANGED
@@ -1,13 +1,20 @@
1
1
  {
2
2
  "name": "wardens",
3
- "version": "0.4.1",
3
+ "version": "0.5.0-rc.0",
4
4
  "description": "A framework for resource management",
5
- "main": "./dist/wardens.umd.js",
6
- "module": "./dist/wardens.es.js",
5
+ "type": "module",
6
+ "main": "./dist/wardens.cjs",
7
+ "module": "./dist/wardens.js",
7
8
  "types": "./src/index.ts",
8
9
  "repository": "git@github.com:PsychoLlama/wardens.git",
9
10
  "author": "Jesse Gibson <JesseTheGibson@gmail.com>",
10
11
  "license": "MIT",
12
+ "exports": {
13
+ ".": {
14
+ "require": "./dist/wardens.cjs",
15
+ "import": "./dist/wardens.js"
16
+ }
17
+ },
11
18
  "files": [
12
19
  "dist",
13
20
  "src"
@@ -22,19 +29,14 @@
22
29
  "framework"
23
30
  ],
24
31
  "scripts": {
25
- "prepare": "tsc && vite build",
32
+ "prepack": "tsc && vite build",
26
33
  "test": "./bin/run-tests",
27
34
  "test:lint": "eslint src --color",
28
- "test:unit": "jest --color",
35
+ "test:unit": "vitest --color",
36
+ "test:types": "vitest typecheck --color",
29
37
  "test:fmt": "prettier --check src --color",
30
38
  "dev": "vite build --watch"
31
39
  },
32
- "exports": {
33
- ".": {
34
- "require": "./dist/wardens.umd.js",
35
- "import": "./dist/wardens.mjs"
36
- }
37
- },
38
40
  "husky": {
39
41
  "hooks": {
40
42
  "pre-commit": "lint-staged"
@@ -60,9 +62,6 @@
60
62
  "files": [
61
63
  "./**/__tests__/*.ts{x,}"
62
64
  ],
63
- "env": {
64
- "jest": true
65
- },
66
65
  "rules": {
67
66
  "@typescript-eslint/no-explicit-any": "off"
68
67
  }
@@ -79,20 +78,15 @@
79
78
  "no-prototype-builtins": "off"
80
79
  }
81
80
  },
82
- "jest": {
83
- "preset": "ts-jest"
84
- },
85
81
  "devDependencies": {
86
- "@types/jest": "29.2.5",
87
- "@typescript-eslint/eslint-plugin": "5.48.1",
88
- "@typescript-eslint/parser": "5.48.1",
89
- "eslint": "8.31.0",
82
+ "@typescript-eslint/eslint-plugin": "6.3.0",
83
+ "@typescript-eslint/parser": "6.3.0",
84
+ "eslint": "8.47.0",
90
85
  "husky": "8.0.3",
91
- "jest": "29.3.1",
92
- "lint-staged": "13.1.0",
93
- "prettier": "2.8.3",
94
- "ts-jest": "29.0.5",
95
- "typescript": "4.9.4",
96
- "vite": "4.0.4"
86
+ "lint-staged": "13.2.3",
87
+ "prettier": "2.8.8",
88
+ "typescript": "5.1.6",
89
+ "vite": "4.4.9",
90
+ "vitest": "^0.34.0"
97
91
  }
98
92
  }
@@ -1,11 +1,12 @@
1
1
  import type ResourceContext from '../resource-context';
2
2
  import { create, destroy } from '../allocation';
3
+ import bindContext from '../bind-context';
3
4
 
4
5
  describe('allocation', () => {
5
6
  describe('create', () => {
6
7
  it('allocates the resource', async () => {
7
8
  const config = { test: 'init-args' };
8
- const Test = jest.fn(
9
+ const Test = vi.fn(
9
10
  async (_resource: ResourceContext, config: { test: string }) => ({
10
11
  value: config,
11
12
  }),
@@ -14,11 +15,50 @@ describe('allocation', () => {
14
15
  await expect(create(Test, config)).resolves.toEqual(config);
15
16
  expect(Test).toHaveBeenCalledWith(expect.anything(), config);
16
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: ResourceContext) => {
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: ResourceContext) => {
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
+ });
17
57
  });
18
58
 
19
59
  describe('destroy', () => {
20
60
  it('deallocates the resource', async () => {
21
- const spy = jest.fn<void, []>();
61
+ const spy = vi.fn<[], void>();
22
62
  const Test = async () => ({
23
63
  value: {},
24
64
  destroy: spy,
@@ -31,6 +71,12 @@ describe('allocation', () => {
31
71
  expect(spy).toHaveBeenCalled();
32
72
  });
33
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
+
34
80
  it('survives if the resource is already deallocated', async () => {
35
81
  const Test = async () => ({ value: [] });
36
82
 
@@ -41,7 +87,7 @@ describe('allocation', () => {
41
87
  });
42
88
 
43
89
  it('automatically unmounts all children', async () => {
44
- const spy = jest.fn();
90
+ const spy = vi.fn();
45
91
  const Child = async () => ({ value: [], destroy: spy });
46
92
  async function Parent(resource: ResourceContext) {
47
93
  await resource.create(Child);
@@ -71,5 +117,120 @@ describe('allocation', () => {
71
117
  const parent = await create(Parent);
72
118
  await expect(destroy(parent)).rejects.toThrow(error);
73
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: ResourceContext) => {
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: ResourceContext) => {
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: ResourceContext) => {
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: ResourceContext) => {
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: ResourceContext) => {
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: ResourceContext) => ({
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
+ });
74
235
  });
75
236
  });
@@ -36,7 +36,7 @@ describe('Method rebinding', () => {
36
36
  });
37
37
 
38
38
  it('copies extended function properties', () => {
39
- const proxy = bind({ mock: jest.fn() });
39
+ const proxy = bind({ mock: vi.fn() });
40
40
 
41
41
  // Mocks use state held on the function itself. This must be added to the
42
42
  // bound functions as well.
@@ -19,7 +19,7 @@ describe('ResourceContext', () => {
19
19
  });
20
20
 
21
21
  it('can deallocate child resources on demand', async () => {
22
- const spy = jest.fn();
22
+ const spy = vi.fn();
23
23
 
24
24
  const Child = async () => ({
25
25
  value: { child: true },
@@ -60,4 +60,16 @@ describe('ResourceContext', () => {
60
60
 
61
61
  await expect(create(Allocator)).resolves.not.toThrow();
62
62
  });
63
+
64
+ it('indicates if a resource was already destroyed', async () => {
65
+ async function Allocator(resource: ResourceContext) {
66
+ const test = await resource.create(Test);
67
+ await resource.destroy(test);
68
+ await resource.destroy(test);
69
+
70
+ return { value: [] };
71
+ }
72
+
73
+ await expect(create(Allocator)).rejects.toThrow(/already destroyed/i);
74
+ });
63
75
  });
@@ -0,0 +1,19 @@
1
+ import { create, ResourceHandle } from '../';
2
+
3
+ describe('Utility types', () => {
4
+ describe('ResourceHandle', () => {
5
+ it('infers the correct type', async () => {
6
+ async function Test() {
7
+ return {
8
+ value: { hello: 'world' },
9
+ };
10
+ }
11
+
12
+ const test = await create(Test);
13
+
14
+ expectTypeOf(test).toEqualTypeOf<ResourceHandle<typeof Test>>({
15
+ hello: 'world',
16
+ });
17
+ });
18
+ });
19
+ });
package/src/allocation.ts CHANGED
@@ -1,10 +1,10 @@
1
- import { resources } from './state';
1
+ import { resources, constructed } from './state';
2
2
  import wrap from './proxy';
3
3
  import ResourceContext from './resource-context';
4
4
  import { ResourceFactory, ParametrizedResourceFactory } from './types';
5
5
 
6
6
  /** Provision a resource and return its external API. */
7
- export async function create<
7
+ export const create = async <
8
8
  Controls extends object,
9
9
  Args extends Array<unknown>,
10
10
  >(
@@ -12,53 +12,115 @@ export async function create<
12
12
  | ParametrizedResourceFactory<Controls, Args>
13
13
  | ResourceFactory<Controls>,
14
14
  ...args: Args
15
- ): Promise<Controls> {
15
+ ): Promise<Controls> => {
16
+ const curfew = { enforced: false };
16
17
  const children: Set<object> = new Set();
17
- const context = new ResourceContext(children);
18
- const resource = await provision(context, ...args);
18
+ const context = new ResourceContext(children, curfew);
19
+ let resource: Awaited<ReturnType<typeof provision>>;
20
+
21
+ try {
22
+ resource = await provision(context, ...args);
23
+ } catch (error) {
24
+ // Resource could not be created. Clean up the intermediate resources.
25
+ const orphans = Array.from(children).reverse();
26
+ const deallocations = await Promise.allSettled(
27
+ orphans.map((orphan) => context.destroy(orphan)),
28
+ );
29
+
30
+ const failures = deallocations.filter(
31
+ (result): result is PromiseRejectedResult => result.status === 'rejected',
32
+ );
33
+
34
+ // The intermediate resources could not be destroyed.
35
+ if (failures.length) {
36
+ throw reduceToSingleError(
37
+ failures.map((failure) => failure.reason),
38
+ { cause: error },
39
+ );
40
+ }
41
+
42
+ throw error;
43
+ }
19
44
 
20
45
  const controls = resource.value;
21
46
  const { proxy, revoke } = wrap(controls);
22
47
 
48
+ constructed.add(proxy);
23
49
  resources.set(proxy, {
50
+ curfew,
24
51
  resource,
25
52
  children,
26
53
  revoke,
27
54
  });
28
55
 
29
56
  return proxy;
30
- }
57
+ };
31
58
 
32
59
  /**
33
60
  * Tear down the resource and all its children, permanently destroying the
34
61
  * reference.
35
- *
36
- * @todo Add type marker to catch cases where the wrong object is unmounted.
37
62
  */
38
- export async function destroy(controls: object) {
39
- const entry = resources.get(controls);
63
+ export const destroy = async (handle: object) => {
64
+ if (!constructed.has(handle)) {
65
+ throw new Error('Cannot destroy object. It is not a resource.');
66
+ }
67
+
68
+ const entry = resources.get(handle);
40
69
 
41
70
  if (entry) {
42
71
  // Instantly delete to prevent race conditions.
43
- resources.delete(controls);
72
+ resources.delete(handle);
44
73
 
45
74
  // Free all references.
46
75
  entry.revoke();
47
76
 
48
- // Recursively close out the children first...
49
- const recursiveUnmounts = Array.from(entry.children).map(destroy);
50
- const results = await Promise.allSettled(recursiveUnmounts);
77
+ let parentDeallocation: PromiseSettledResult<void> = {
78
+ status: 'fulfilled',
79
+ value: undefined,
80
+ };
51
81
 
52
- // Then close the parent.
82
+ // Try to close the parent resource...
53
83
  if (entry.resource.destroy) {
54
- await entry.resource.destroy();
84
+ try {
85
+ await entry.resource.destroy();
86
+ } catch (error) {
87
+ parentDeallocation = { status: 'rejected', reason: error };
88
+ }
55
89
  }
56
90
 
91
+ // The resource is closed. Prevent new child resources before we start
92
+ // closing down the children.
93
+ entry.curfew.enforced = true;
94
+
95
+ // Recursively close out the children...
96
+ const recursiveUnmounts = Array.from(entry.children).reverse().map(destroy);
97
+ const deallocations = await Promise.allSettled(recursiveUnmounts);
98
+ const failures = [parentDeallocation]
99
+ .concat(deallocations)
100
+ .filter(
101
+ (result): result is PromiseRejectedResult =>
102
+ result.status === 'rejected',
103
+ );
104
+
57
105
  // Fail loudly if any of the children couldn't be deallocated.
58
- results.forEach((result) => {
59
- if (result.status === 'rejected') {
60
- throw result.reason;
61
- }
62
- });
106
+ if (failures.length) {
107
+ throw reduceToSingleError(failures.map((failure) => failure.reason));
108
+ }
109
+ }
110
+ };
111
+
112
+ const reduceToSingleError = (errors: Array<Error>, options?: ErrorOptions) => {
113
+ return errors.length === 1
114
+ ? errors[0]
115
+ : new BulkDestroyError(errors, options);
116
+ };
117
+
118
+ /** Happens when 2 or more child resources cannot be destroyed. */
119
+ class BulkDestroyError extends Error {
120
+ constructor(public failures: Array<unknown>, options?: ErrorOptions) {
121
+ super(
122
+ 'Some resources could not be destroyed. See the `failures` property for details.',
123
+ options,
124
+ );
63
125
  }
64
126
  }
package/src/index.ts CHANGED
@@ -1,4 +1,4 @@
1
1
  export { default as bindContext } from './bind-context';
2
2
  export { create, destroy } from './allocation';
3
- export type { Resource } from './types';
3
+ export type { Resource, ResourceHandle } from './types';
4
4
  export type { default as ResourceContext } from './resource-context';
@@ -1,4 +1,5 @@
1
1
  import { create, destroy } from './allocation';
2
+ import { RevokableResource } from './state';
2
3
  import { ResourceFactory, ParametrizedResourceFactory } from './types';
3
4
 
4
5
  /**
@@ -7,10 +8,16 @@ import { ResourceFactory, ParametrizedResourceFactory } from './types';
7
8
  * track of ownership and lifetimes.
8
9
  */
9
10
  export default class ResourceContext {
11
+ #destroyed = new WeakSet<object>();
10
12
  #resources: Set<object>;
13
+ #freeze: RevokableResource['curfew'];
11
14
 
12
- constructor(ownedResources: Set<object>) {
15
+ constructor(
16
+ ownedResources: Set<object>,
17
+ freeze: RevokableResource['curfew'],
18
+ ) {
13
19
  this.#resources = ownedResources;
20
+ this.#freeze = freeze;
14
21
  }
15
22
 
16
23
  /** Provision an owned resource and make sure it doesn't outlive us. */
@@ -20,6 +27,10 @@ export default class ResourceContext {
20
27
  | ResourceFactory<Controls>,
21
28
  ...args: Args
22
29
  ): Promise<Controls> => {
30
+ if (this.#freeze.enforced) {
31
+ throw new Error('Cannot create new resources after teardown.');
32
+ }
33
+
23
34
  const controls = await create(factory, ...args);
24
35
  this.#resources.add(controls);
25
36
 
@@ -31,14 +42,16 @@ export default class ResourceContext {
31
42
  * deallocated.
32
43
  */
33
44
  public destroy = async (resource: object) => {
45
+ if (this.#destroyed.has(resource)) {
46
+ throw new Error('Resource already destroyed.');
47
+ }
48
+
34
49
  if (!this.#resources.has(resource)) {
35
50
  throw new Error('You do not own this resource.');
36
51
  }
37
52
 
38
- try {
39
- await destroy(resource);
40
- } finally {
41
- this.#resources.delete(resource);
42
- }
53
+ this.#resources.delete(resource);
54
+ this.#destroyed.add(resource);
55
+ await destroy(resource);
43
56
  };
44
57
  }
package/src/state.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { Resource } from './types';
2
2
 
3
- interface RevokableResource {
3
+ export interface RevokableResource {
4
4
  resource: Resource<object>;
5
5
 
6
6
  /** All the resources this resource personally allocated. */
@@ -11,7 +11,20 @@ interface RevokableResource {
11
11
  * collection.
12
12
  */
13
13
  revoke(): void;
14
+
15
+ /**
16
+ * A flag preventing the resource context from provisioning new child
17
+ * resources after the parent is destroyed.
18
+ */
19
+ curfew: { enforced: boolean };
14
20
  }
15
21
 
16
22
  /** Maps an external API back to the resource that created it. */
17
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/types.ts CHANGED
@@ -1,4 +1,3 @@
1
- /* eslint-disable @typescript-eslint/no-explicit-any */
2
1
  import type ResourceContext from './resource-context';
3
2
 
4
3
  /**
@@ -6,15 +5,15 @@ import type ResourceContext from './resource-context';
6
5
  * and destroyed. Resources can own other resources, and destroying a parent
7
6
  * first tears down the children.
8
7
  */
9
- export interface ResourceFactory<Controls extends object> {
10
- (resource: ResourceContext): Promise<Resource<Controls>>;
8
+ export interface ResourceFactory<Value extends object> {
9
+ (resource: ResourceContext): Promise<Resource<Value>>;
11
10
  }
12
11
 
13
12
  export interface ParametrizedResourceFactory<
14
- Controls extends object,
13
+ Value extends object,
15
14
  Args extends Array<unknown>,
16
15
  > {
17
- (resource: ResourceContext, ...args: Args): Promise<Resource<Controls>>;
16
+ (resource: ResourceContext, ...args: Args): Promise<Resource<Value>>;
18
17
  }
19
18
 
20
19
  export interface Resource<Value extends object> {
@@ -24,3 +23,8 @@ export interface Resource<Value extends object> {
24
23
  /** A hook that gets called when the resource is destroyed. */
25
24
  destroy?(): Promise<unknown> | unknown;
26
25
  }
26
+
27
+ /** The `value` type returned when creating a resource. */
28
+ export type ResourceHandle<Factory extends ResourceFactory<object>> = Awaited<
29
+ ReturnType<Factory>
30
+ >['value'];
package/dist/wardens.mjs DELETED
@@ -1,87 +0,0 @@
1
- var p = Object.defineProperty;
2
- var x = (n, e, t) => e in n ? p(n, e, { enumerable: !0, configurable: !0, writable: !0, value: t }) : n[e] = t;
3
- var a = (n, e, t) => (x(n, typeof e != "symbol" ? e + "" : e, t), t), f = (n, e, t) => {
4
- if (!e.has(n))
5
- throw TypeError("Cannot " + t);
6
- };
7
- var i = (n, e, t) => (f(n, e, "read from private field"), t ? t.call(n) : e.get(n)), y = (n, e, t) => {
8
- if (e.has(n))
9
- throw TypeError("Cannot add the same private member more than once");
10
- e instanceof WeakSet ? e.add(n) : e.set(n, t);
11
- }, l = (n, e, t, o) => (f(n, e, "write to private field"), o ? o.call(n, t) : e.set(n, t), t);
12
- function g(n) {
13
- const e = /* @__PURE__ */ new WeakMap();
14
- return new Proxy(n, {
15
- get(t, o) {
16
- const r = Reflect.get(t, o, t);
17
- if (typeof r == "function") {
18
- if (e.has(r) === !1) {
19
- const c = r.bind(t);
20
- Object.defineProperties(
21
- c,
22
- Object.getOwnPropertyDescriptors(r)
23
- ), e.set(r, c);
24
- }
25
- return e.get(r);
26
- }
27
- return r;
28
- },
29
- set(t, o, r) {
30
- return Reflect.set(t, o, r, t);
31
- }
32
- });
33
- }
34
- const u = /* @__PURE__ */ new WeakMap();
35
- function m(n) {
36
- return Proxy.revocable(n, {});
37
- }
38
- var s;
39
- class v {
40
- constructor(e) {
41
- y(this, s, void 0);
42
- /** Provision an owned resource and make sure it doesn't outlive us. */
43
- a(this, "create", async (e, ...t) => {
44
- const o = await P(e, ...t);
45
- return i(this, s).add(o), o;
46
- });
47
- /**
48
- * Tear down a resource. Happens automatically when resource owners are
49
- * deallocated.
50
- */
51
- a(this, "destroy", async (e) => {
52
- if (!i(this, s).has(e))
53
- throw new Error("You do not own this resource.");
54
- try {
55
- await w(e);
56
- } finally {
57
- i(this, s).delete(e);
58
- }
59
- });
60
- l(this, s, e);
61
- }
62
- }
63
- s = new WeakMap();
64
- async function P(n, ...e) {
65
- const t = /* @__PURE__ */ new Set(), o = new v(t), r = await n(o, ...e), c = r.value, { proxy: d, revoke: h } = m(c);
66
- return u.set(d, {
67
- resource: r,
68
- children: t,
69
- revoke: h
70
- }), d;
71
- }
72
- async function w(n) {
73
- const e = u.get(n);
74
- if (e) {
75
- u.delete(n), e.revoke();
76
- const t = Array.from(e.children).map(w), o = await Promise.allSettled(t);
77
- e.resource.destroy && await e.resource.destroy(), o.forEach((r) => {
78
- if (r.status === "rejected")
79
- throw r.reason;
80
- });
81
- }
82
- }
83
- export {
84
- g as bindContext,
85
- P as create,
86
- w as destroy
87
- };
@@ -1 +0,0 @@
1
- var P=Object.defineProperty;var g=(e,t,n)=>t in e?P(e,t,{enumerable:!0,configurable:!0,writable:!0,value:n}):e[t]=n;var l=(e,t,n)=>(g(e,typeof t!="symbol"?t+"":t,n),n),p=(e,t,n)=>{if(!t.has(e))throw TypeError("Cannot "+n)};var f=(e,t,n)=>(p(e,t,"read from private field"),n?n.call(e):t.get(e)),b=(e,t,n)=>{if(t.has(e))throw TypeError("Cannot add the same private member more than once");t instanceof WeakSet?t.add(e):t.set(e,n)},m=(e,t,n,d)=>(p(e,t,"write to private field"),d?d.call(e,n):t.set(e,n),n);(function(e,t){typeof exports=="object"&&typeof module<"u"?t(exports):typeof define=="function"&&define.amd?define(["exports"],t):(e=typeof globalThis<"u"?globalThis:e||self,t(e.wardens={}))})(this,function(e){var a;"use strict";function t(c){const o=new WeakMap;return new Proxy(c,{get(s,i){const r=Reflect.get(s,i,s);if(typeof r=="function"){if(o.has(r)===!1){const u=r.bind(s);Object.defineProperties(u,Object.getOwnPropertyDescriptors(r)),o.set(r,u)}return o.get(r)}return r},set(s,i,r){return Reflect.set(s,i,r,s)}})}const n=new WeakMap;function d(c){return Proxy.revocable(c,{})}class x{constructor(o){b(this,a,void 0);l(this,"create",async(o,...s)=>{const i=await h(o,...s);return f(this,a).add(i),i});l(this,"destroy",async o=>{if(!f(this,a).has(o))throw new Error("You do not own this resource.");try{await y(o)}finally{f(this,a).delete(o)}});m(this,a,o)}}a=new WeakMap;async function h(c,...o){const s=new Set,i=new x(s),r=await c(i,...o),u=r.value,{proxy:w,revoke:v}=d(u);return n.set(w,{resource:r,children:s,revoke:v}),w}async function y(c){const o=n.get(c);if(o){n.delete(c),o.revoke();const s=Array.from(o.children).map(y),i=await Promise.allSettled(s);o.resource.destroy&&await o.resource.destroy(),i.forEach(r=>{if(r.status==="rejected")throw r.reason})}}e.bindContext=t,e.create=h,e.destroy=y,Object.defineProperty(e,Symbol.toStringTag,{value:"Module"})});