wardens 0.4.1 → 0.5.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 +19 -1
- package/dist/wardens.cjs +1 -0
- package/dist/wardens.js +125 -0
- package/package.json +21 -27
- package/src/__tests__/allocation.test.ts +164 -3
- package/src/__tests__/bind-context.test.ts +1 -1
- package/src/__tests__/resource-context.test.ts +13 -1
- package/src/__tests__/types.test-d.ts +19 -0
- package/src/allocation.ts +83 -21
- package/src/index.ts +1 -1
- package/src/resource-context.ts +19 -6
- package/src/state.ts +14 -1
- package/src/types.ts +9 -5
- package/dist/wardens.mjs +0 -87
- package/dist/wardens.umd.js +0 -1
package/CHANGELOG.md
CHANGED
@@ -6,6 +6,23 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
|
7
7
|
## [Unreleased]
|
8
8
|
|
9
|
+
## [0.5.0]
|
10
|
+
|
11
|
+
### Changed
|
12
|
+
|
13
|
+
- Wardens is now published with ESM (`type=module`). It should be backwards compatible.
|
14
|
+
- Now `destroy(...)` throws if you pass an object that wasn't constructed with `create(...)`.
|
15
|
+
|
16
|
+
### Fixed
|
17
|
+
|
18
|
+
- If a resource fails while initializing, now all intermediate child resources are destroyed as well.
|
19
|
+
- If a resource fails while being destroyed, now its child resources are destroyed as well.
|
20
|
+
- Resources can no longer provision child resources after teardown. This closes a loophole where resources could escape destruction.
|
21
|
+
|
22
|
+
### Added
|
23
|
+
|
24
|
+
- New `ResourceHandle<T>` utility type represents the value returned when creating a resource.
|
25
|
+
|
9
26
|
## [0.4.1] - 2023-01-14
|
10
27
|
|
11
28
|
### Fixed
|
@@ -60,7 +77,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
60
77
|
- `mount`/`unmount` hooks to provision resources
|
61
78
|
- `allocate`/`deallocate` for creating hierarchies of resources
|
62
79
|
|
63
|
-
[Unreleased]: https://github.com/PsychoLlama/wardens/compare/v0.
|
80
|
+
[Unreleased]: https://github.com/PsychoLlama/wardens/compare/v0.5.0...HEAD
|
81
|
+
[0.5.0]: https://github.com/PsychoLlama/wardens/compare/v0.4.1...v0.5.0
|
64
82
|
[0.4.1]: https://github.com/PsychoLlama/wardens/compare/v0.4.0...v0.4.1
|
65
83
|
[0.4.0]: https://github.com/PsychoLlama/wardens/compare/v0.3.0...v0.4.0
|
66
84
|
[0.3.0]: https://github.com/PsychoLlama/wardens/compare/v0.2.0...v0.3.0
|
package/dist/wardens.cjs
ADDED
@@ -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;
|
package/dist/wardens.js
ADDED
@@ -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.
|
3
|
+
"version": "0.5.0",
|
4
4
|
"description": "A framework for resource management",
|
5
|
-
"
|
6
|
-
"
|
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
|
-
"
|
32
|
+
"prepack": "tsc && vite build",
|
26
33
|
"test": "./bin/run-tests",
|
27
34
|
"test:lint": "eslint src --color",
|
28
|
-
"test:unit": "
|
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
|
-
"@
|
87
|
-
"@typescript-eslint/
|
88
|
-
"
|
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
|
-
"
|
92
|
-
"
|
93
|
-
"
|
94
|
-
"
|
95
|
-
"
|
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 =
|
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 =
|
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 =
|
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:
|
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 =
|
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
|
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
|
-
|
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
|
39
|
-
|
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(
|
72
|
+
resources.delete(handle);
|
44
73
|
|
45
74
|
// Free all references.
|
46
75
|
entry.revoke();
|
47
76
|
|
48
|
-
|
49
|
-
|
50
|
-
|
77
|
+
let parentDeallocation: PromiseSettledResult<void> = {
|
78
|
+
status: 'fulfilled',
|
79
|
+
value: undefined,
|
80
|
+
};
|
51
81
|
|
52
|
-
//
|
82
|
+
// Try to close the parent resource...
|
53
83
|
if (entry.resource.destroy) {
|
54
|
-
|
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
|
-
|
59
|
-
|
60
|
-
|
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';
|
package/src/resource-context.ts
CHANGED
@@ -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(
|
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
|
-
|
39
|
-
|
40
|
-
|
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<
|
10
|
-
(resource: ResourceContext): Promise<Resource<
|
8
|
+
export interface ResourceFactory<Value extends object> {
|
9
|
+
(resource: ResourceContext): Promise<Resource<Value>>;
|
11
10
|
}
|
12
11
|
|
13
12
|
export interface ParametrizedResourceFactory<
|
14
|
-
|
13
|
+
Value extends object,
|
15
14
|
Args extends Array<unknown>,
|
16
15
|
> {
|
17
|
-
(resource: ResourceContext, ...args: Args): Promise<Resource<
|
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
|
-
};
|
package/dist/wardens.umd.js
DELETED
@@ -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"})});
|